"""
File Purpose: Registry and Registerable class
"""
import collections
import os
import sqlite3

from .metadata_haver import MetadataHaver, UuidHaver
from .registry_tools import (
    registry_location, registry_schema_sql, registry_name,
    Sqlite3InMemoryConnection,
)
from ..errors import (
    InputError, DimensionalityError,
    RegistryConflictError, RegistryContentsError,
)
from ..tools import (
    UNSET,
    is_number,
    datetime_now,
    _PlasmaCalcs_version,
)


### --------------------- Registry --------------------- ###

class Registry():
    '''registry class which handles adding entries to a registry database (using SQL).
    (Eventually may have engines besides sqlite3, but only using sqlite3 for now.)

    location: UNSET, None, or str
        UNSET --> use registry implied by DEFAULTS.REGISTRY_LOCATION
            (if DEFAULTS.REGISTRY_LOCATION=None, use registry_location_default(),
             which is system-dependent, e.g. "~/.plasmacalcs/pc_registry.db" on Linux.)
        None --> in-memory registry, equivalent to ":memory:", will not edit any files.
        str --> filepath to registry database file.
    name: UNSET, None, or str
        UNSET --> use basename of location. E.g. "pc_registry.db" by default.
            (if location in-memory, equivalent to name=None)
        None --> no name; use '_unnamed_<uuid>' as name,
            filling <uuid> with auto-generated unique hex string for this registry.
        str --> use this for registry name.
        Note that registry name is stored in registry: with self.connect() as con:
            name == con.execute('SELECT name FROM origins WHERE origin_id = 0').fetchone()[0]
    assert_exists: None, True, or False
        True --> assert that implied location already exists as a file.
        False --> assert that implied location does not yet exist as a file.
        None --> no assertion is made.
        (Here, ':memory:' never "already exists", since it always means a new database.)

    Database schema looks roughly like this:
        runs: table of runs (or observations or other data source)
            run_id (PK): unique id for each run in this database.
            origin_id (FK): tells which origin this run came from (see below)
            origin_run_id: tells run_id of this entry in its original registry (NULL when origin_id=0)
            pc_uuid: unique uuid for this run. Saved to _pc_uuid.txt in the run directory.
            registered_at_first: datetime when this run was first registered
            registered_at: datetime when this run's data was last updated
            calculator_type: name of PlasmaCalculator class which called .register()
            ... more metadata about the run, e.g. datetime_run
        params: table of values of all input parameters across all runs
            param_id (PK): unique id for each param entry
            run_id (FK): run_id associated with this entry
            name: name of this param
            value: value of this param (if numerical), must be a single scalar
            value_text: value of this param (if string or anything more complicated like a list)
        metrics: table of values of all metrics across all runs
            metric_id (PK): unique id for each metric entry
            run_id (FK): run_id associated with this entry
            name: name of this metric
            value: value of this metric (numerical)
            is_pc_quantity: bool telling whether this metric was computed directly as a quantity or not
        register_history: table tracking calls to register() which caused any changes to database.
            history_id (PK): unique id for each history entry
            run_id (FK): run_id associated with this entry
            origin_id (FK): origin_id telling which origin this register() call occurred on.
            entered_at: datetime when this call to register() occurred
            pc_version: PlasmaCalcs.__version__ when .register() was called
            updated_metadata: list of metadata updated (i.e. value changed) by this call to register()
            updated_metrics: list of metrics updated (i.e. value changed) by this call to register()
        origins: table of origins (e.g. "which database did this entry come from")
            origin_id (PK): unique id for each origin in this database.
                origin_id = 0 is reserved for self, with name as canonical name for this database;
                name set to '__self__' by sql defaults, or set to "_unnamed_<short_uuid>" by python defaults,
                replacing <short_uuid> with unique identifier string, e.g. 'e0b4460e'.
            name: unique name for this origin
    
    Merging databases will alter run_id for runs, such that run_id throughout the result is unique.
    Merged databases will include multiple entries in origins table; unmerged will have only origin_id=0.

    Online databases will alter run_id and origin_id as needed
    "Merged" and "online" database schema will add (to facilitate finding run from original database):
        sources: table of sources (e.g. users, databases) from which this database's data originated
            source_id: unique id for each source
            source_run_id: run_id of this run within its source database
            run_id: run id associated with this entry
    '''
    RegistrationResult = collections.namedtuple('RegistrationResult', ('run_id', 'history_id'))
    registration_result_cls = RegistrationResult   # override registration_result_cls to utilize different output.

    def __init__(self, location=UNSET, *, name=UNSET, assert_exists=None):
        '''initialize registry with this location; see help(type(self)) for details.'''
        self.location = registry_location(location, assert_exists=assert_exists)
        self.name = registry_name(self.location, name)

    # # # SQL CONNECTION AND CROSS-ENGINE COMPATIBILITY # # #
    def connect(self):
        '''return a connection to this registry's database.
        (If the database doesn't exist yet, create it using the registry_schema_sql() script.)
        (If the database name (origins origin_id=0 name) is '__self__', update it to self.name,
            otherwise update self.name to match it.) 

        Can be used as a context manager to ensure it is closed properly:
            with registry.connect() as con:
                results = con.execute('SELECT ...').fetchall()
        Else, caller is responsible for closing the connection via con.close().
        '''
        if self.location == ':memory:':
            # cache in-memory connection, so data persists for the lifetime of this Registry object.
            con = getattr(self, '_in_memory_con', None)
            exists = (con is not None)
            if not exists:
                con = Sqlite3InMemoryConnection()
                self._in_memory_con = con
        else:  # file-based database
            exists = os.path.exists(self.location)
            if not exists:  # makedirs to file if needed.
                os.makedirs(os.path.dirname(self.location), exist_ok=True)
            con = sqlite3.connect(self.location)
        if not exists:  # need to initialize schema
            schema_sql = registry_schema_sql()
            con.executescript(schema_sql)
        # set name as needed:
        with con:
            name = con.execute('SELECT name FROM origins WHERE origin_id = 0').fetchone()[0]
            if name == '__self__':
                assert isinstance(self.name, str)
                con.execute('UPDATE origins SET name = ? WHERE origin_id = 0', (self.name,))
            else:
                self.name = name
        return con

    def execute(self, *args, **kwargs):
        '''shortcut for with self.connect() as con: return con.execute(*args, **kwargs)'''
        with self.connect() as con:
            return con.execute(*args, **kwargs)

    # # # SANITIZING # # #
    def sanitize_value(self, v):
        '''return sanitized value v, so that it can be stored in a SQL database.
        Sanitized means number, None, or string.
        '''
        if is_number(v):
            return v
        elif v is None:
            return None
        else:
            return str(v)

    def sanitize_values(self, d):
        '''return copy of dict d but with values sanitized to number, None, or string,
        so that they can be stored in a SQL database.
        '''
        sanitized = {}
        for k, v in d.items():
            sanitized[k] = self.sanitize_value(v)
        return sanitized

    # # # UNIQUENESS # # #
    def run_id(self, metadata, sanitized_params):
        '''return the run_id of this run within the registry, or None if it doesn't exist yet.
        Compares metadata['pc_uuid'] with [pc_uuid from runs table] to find existing run,
        then compares params (to properly handle the unlikely case of accidental uuid overlap).

        (Raise RegistryContentsError if pc_uuid exists but params don't match.)
        '''
        uuid = metadata['pc_uuid']
        with self.connect() as con:
            row = con.execute('''
                SELECT run_id FROM runs WHERE pc_uuid = ?
            ''', (uuid,)).fetchone()  # schema guarantees unique within db.
            if row is None:
                return None
            row_id = row[0]
        # get params for this run_id; check match below.
        # (conceptually different than matching uuid, but included in same connect() for efficiency.
            fetched_param_rows = con.execute('''
                SELECT name, value, value_text FROM params WHERE run_id = ?
            ''', (row_id,)).fetchall()
        self._assert_all_params_match(sanitized_params, fetched_param_rows)
        return row_id

    def _assert_all_params_match(self, sanitized_params, fetched_param_rows):
        '''check that all sanitized_params match fetched_param_rows from database.
        Crash with RegistryConflictError if params don't all match.
        '''
        registered_params = {name: value_text if value is None else value
                                    for name, value, value_text in fetched_param_rows}
        old_keys = set(registered_params.keys())
        new_keys = set(sanitized_params.keys())
        if not new_keys == old_keys:
            extra_new = new_keys - old_keys
            extra_old = old_keys - new_keys
            errmsg = f"Existing run (run_id={run_id}) has same uuid ({uuid!r}) but different params."
            if extra_new:
                errmsg += f"\nNot-yet-registered run has params not in existing run: {extra_new}"
            if extra_old:
                errmsg += f"\nExisting run has params not in not-yet-registered run: {extra_old}"
            raise RegistryConflictError(errmsg)
        # else:  # keys match, need to check values
        for k in old_keys:
            v_old = registered_params[k]
            v_new = sanitized_params[k]
            if v_old != v_new:
                errmsg = (f"Existing run (run_id={run_id}) has same uuid ({uuid!r}) but different params."
                          f"Param {k!r}: existing_value={v_old!r}; not_yet_registered_value={v_new!r}")
                raise RegistryConflictError(errmsg)
        return True  # not necessary but explicit


    _REGISTER_META_METADATA = ('origin_id', 'origin_run_id', 'registered_at_first', 'registered_at')

    def run_metadata_and_sanitized_params(self, run_id):
        '''returns dict of metadata and dict of sanitized_params, for run with this run_id in self.
        metadata includes all "run" metadata but not any of the "register-related" metadata,
            i.e. it excludes origin_id, origin_run_id, registered_at_first, registered_at.
        '''
        with self.connect() as con:
            metadata_row = con.execute('''
                SELECT * FROM runs WHERE run_id = ?
            ''', (run_id,)).fetchone()
            if metadata_row is None:
                raise RegistryContentsError(f"run_id {run_id} not found in registry.")
            metadata_columns = self.columns('runs')
            metadata = {col: val for col, val in zip(metadata_columns, metadata_row)
                        if col not in self._REGISTER_META_METADATA}
            param_rows = con.execute('''
                SELECT name, value, value_text FROM params WHERE run_id = ?
            ''', (run_id,)).fetchall()
            sanitized_params = {name: value_text if value is None else value
                                    for name, value, value_text in param_rows}
        return metadata, sanitized_params

    # # # REGISTER # # #
    def register(self, metadata, params, metrics):
        '''register an entry with this metadata, params, and metrics.
        Adds some registration-related metadata like registered_at = current datetime.
        Returns (run_id, history_id) of this run and register_history entry within the registry.
            (history_id will be None if this run already existed and no values were updated.)
            (output will be a namedtuple to clarify meaning; can be treated as a tuple if desired.)

        metadata: dict
            metadata about the data source (simulation run / observation / other data source)
            Will be stored in the "runs" table.
        params: dict
            parameters used to generate the data source
            Will be sanitized then stored in the "params" table.
            (Sanitizing converts to numerical value or string representation.)
        metrics: dict of (bool, value)
            metrics computed from the data source
            Will be stored in the "metrics" table.
            bool indicates whether this metric was computed directly as a quantity.
                If True, it was computed via calculator(key).
                Else, it was provided directly by user.
        '''
        sanitized_params = self.sanitize_values(params)
        run_id = self.run_id(metadata, sanitized_params)
        kws = dict(run_id=run_id, metadata=metadata, sanitized_params=sanitized_params, metrics=metrics)
        if run_id is None:  # insert new run.
            (run_id, history_id) = self._register_new_run(**kws)
        else:
            (run_id, history_id) = self._register_update_run(**kws)
        return self.registration_result_cls(run_id, history_id)

    def _register_new_run(self, *, run_id=None, metadata, sanitized_params, metrics):
        '''registers new run, returning (run_id, history_id) of the newly registered run.
        Use register() instead; not for direct usage (but subclass can override). 
        Assumes, without checking:
            - self.run_id(metadata, sanitized_params) is None
            - sanitized_params is a dict with values only numbers and strings.

        run_id is provided by self.register() in case subclass wants to use it,
            but the implementation here does not check provided run_id at all.
        '''
        with self.connect() as con:
            # One row at a time for readability; can improve efficiency later if needed.
            cur = con.cursor()
            now = datetime_now(tz='utc', sep='T')
            # insert into runs table (adding metadata columns as needed first)
            missing = self.missing_columns('runs', metadata.keys())
            for col in missing:
                cur.execute(f'ALTER TABLE runs ADD COLUMN {col} TEXT')
            columns = ['origin_id', # 'origin_run_id' is NULL for all runs registered directly.
                        *metadata.keys(),
                        'registered_at_first', 'registered_at']
            run_id = cur.execute(f'''
                INSERT INTO runs ({', '.join(columns)})
                VALUES ({', '.join(['?'] * len(columns))})
                RETURNING run_id
            ''', (0, *metadata.values(), now, now)  # 0 for origin_id of self.
            ).fetchone()[0]
            # insert into params table
            for name, value in sanitized_params.items():
                if isinstance(value, str):
                    cur.execute('''
                        INSERT INTO params (run_id, name, value_text)
                        VALUES (?, ?, ?)
                    ''', (run_id, name, value))
                else:
                    cur.execute('''
                        INSERT INTO params (run_id, name, value)
                        VALUES (?, ?, ?)
                    ''', (run_id, name, value))
            # insert into metrics table
            for name, (is_pc_quantity, value) in metrics.items():
                cur.execute('''
                    INSERT INTO metrics (run_id, name, value, is_pc_quantity)
                    VALUES (?, ?, ?, ?)
                ''', (run_id, name, value, is_pc_quantity))
            # insert into register_history table
            pc_version = _PlasmaCalcs_version()
            updated_metrics = metrics.keys()  # all metrics were "updated"; this is a new run.
            updated_metadata = metadata.keys()  # all metadata values were "updated"; this is a new run.
            history_id = cur.execute('''
                INSERT INTO register_history (origin_id, run_id, entered_at, pc_version,
                                              updated_metadata, updated_metrics)
                VALUES (?, ?, ?, ?, ?, ?)
                RETURNING history_id
            ''', (0, run_id, now, pc_version, ', '.join(updated_metadata), ', '.join(updated_metrics))
            ).fetchone()[0]
        return (run_id, history_id)

    def _register_update_run(self, *, run_id, metadata, sanitized_params=None, metrics):
        '''updates metadata, metrics, and register_history for existing run with this run_id.
        Use register() instead; not for direct usage (but subclass can override).
        Assumes, without checking:
            - run_id exists in database and has all matching sanitized_params for this run

        sanitized_params is provided by self.register() in case subclass wants to use it,
            but the implementation here does not check provided sanitized_params at all.

        returns (run_id, history_id) of the updated run and register_history entry.
            (history_id will be None if no values were updated.)
        '''
        with self.connect() as con:
            cur = con.cursor()
            now = datetime_now(tz='utc', sep='T')
            # update metadata table
            updated_metadata = []
            missing_metakeys = self.missing_columns('runs', metadata.keys())
            for key, value in metadata.items():
                if key in missing_metakeys:  # key is missing --> add column
                    cur.execute(f'ALTER TABLE runs ADD COLUMN {key} TEXT')
                    updated = True
                else:  # key exists --> update value if it changed
                    cur.execute(f'''
                        UPDATE runs
                        SET {key} = ?
                        WHERE run_id = ?
                            AND {key} IS DISTINCT FROM ?
                        RETURNING run_id   -- returns something if value actually changed
                    ''', (value, run_id, value))
                    _run_id = cur.fetchone()
                    updated = (_run_id is not None)
                # if updated, track it for register_history:
                if updated:
                    updated_metadata.append(key)
            # update metrics table:
            updated_metrics = []
            for name, (is_pc_quantity, value) in metrics.items():
                # if (run_id, name) is missing, insert it, else update value (if it changed)
                metric_exists = cur.execute('''
                    SELECT metric_id from metrics
                    WHERE run_id = ? AND name = ?
                    ''', (run_id, name)).fetchone()
                metric_missing = (metric_exists is None)
                if metric_missing:  # (run_id, name) is missing --> insert
                    cur.execute('''
                        INSERT INTO metrics (run_id, name, value, is_pc_quantity)
                        VALUES (?, ?, ?, ?)
                    ''', (run_id, name, value, is_pc_quantity))
                    updated = True
                else:  # (run_id, name) exists --> update (value, is_pc_quantity) if it changed
                    cur.execute('''
                        UPDATE metrics
                        SET value = ?, is_pc_quantity = ?
                        WHERE run_id = ? AND name = ?
                            AND (   -- check if value actually changed.
                                 value IS DISTINCT FROM ?
                                 OR is_pc_quantity IS DISTINCT FROM ?
                                )
                        RETURNING metric_id   -- returns something if value actually changed
                    ''', (value, is_pc_quantity, run_id, name, value, is_pc_quantity))
                    _metric_id = cur.fetchone()
                    updated = (_metric_id is not None)
                # if updated, track it for register_history:
                if updated:
                    updated_metrics.append(name)
            # if anything was actually updated, add register_history entry and update registered_at
            if updated_metadata or updated_metrics:
                # update registered_at
                cur.execute('''
                    UPDATE runs
                    SET registered_at = ?
                    WHERE run_id = ?
                ''', (now, run_id))
                # update register_history
                pc_version = _PlasmaCalcs_version()
                history_id = cur.execute('''
                    INSERT INTO register_history (origin_id, run_id, entered_at, pc_version,
                                                  updated_metadata, updated_metrics)
                    VALUES (?, ?, ?, ?, ?, ?)
                    RETURNING history_id
                ''', (0, run_id, now, pc_version, ', '.join(updated_metadata), ', '.join(updated_metrics))
                ).fetchone()[0]
            else:
                history_id = None
        return (run_id, history_id)

    # # # MERGING / HANDLING SOURCE_ID # # #
    def merge(self, *others, dst=None):
        '''merge self other registry(s), writing result to dst, and returning Registry(dst).
        dst must represent a new registry; this operation never alters an existing registry.

        For any runs appearing in multiple registries (based on pc_uuid),
            only keeps values from first registry in which is appears.
            ([TODO] more leniency, e.g. also merge those runs' metrics data from later registries.)

        others: each is a Registry or str
            each is a registry to merge into the result.
            for str, assert it points to an existing file, then use other=type(self)(dst).
        dst: None or str
            destination for merge operation.
            None --> like location=None or ":memory:"; creates a new in-memory registry.
            str --> create registry at this filepath. It must not yet exist.
                (internally, appends ".db" extension if no file extension detected.)
        '''
        # checks & setup:
        for i, other in enumerate(others):
            if isinstance(other, str):
                others[i] = type(self)(other, assert_exists=True)
        result = type(self)(dst, assert_exists=False)

        # adding other registries' data to result, one at a time:
        with result.connect() as res:
            for reghere in (self, *others):
                with reghere.connect() as here:
                    # can't just do a full backup, because we need to put new origin_id appropriately.
                    # (we also need to add origin_run_id to each run)
                    # so we iterate through runs, copying them one at a time.
                    # first, iterate through origins:
                    origins_here = here.execute('SELECT origin_id, name FROM origins').fetchall()
                    map_origin_id = {}
                    for origin_id, name in origins_here:
                        new_origin_id = res.execute('''
                            INSERT INTO origins (name)   -- UNIQUE constraint elegantly prevents duplicates :)
                            VALUES (?)
                            RETURNING origin_id
                        ''', (name,)
                        ).fetchone()[0]
                        map_origin_id[origin_id] = new_origin_id
                    map_run_id = {}
                    # next, iterate through runs:  # [TODO] bulk insert runs, too?
                    run_columns = reghere.columns('runs')
                    runs_here = here.execute('SELECT * FROM runs').fetchall()
                    for run in runs_here:
                        run_dict = dict(zip(run_columns, run))
                        old_run_id = run_dict['run_id']
                        old_origin_id = run_dict['origin_id']
                        run_dict['origin_id'] = map_origin_id[old_origin_id]
                        run_dict['origin_run_id'] = old_run_id
                        del run_dict['run_id']
                        metadata, sanitized_params = reghere.run_metadata_and_sanitized_params(old_run_id)
                        new_run_id = result.run_id(metadata, sanitized_params)
                        if new_run_id is None:  # i.e. run doesn't exist in result yet
                            for col in result.missing_columns('runs', run_columns):
                                res.execute(f'ALTER TABLE runs ADD COLUMN {col} TEXT')
                            new_run_id = res.execute(f'''
                                INSERT INTO runs ({', '.join(run_dict.keys())})
                                VALUES ({', '.join(['?'] * len(run_dict))})
                                RETURNING run_id
                            ''', tuple(run_dict.values())
                            ).fetchone()[0]
                            map_run_id[old_run_id] = new_run_id
                        # else, don't add to map_run_id
                    # add all params, metrics, and register_history entries.
                    for table in ('params', 'metrics', 'register_history'):
                        pk = reghere.primary_key(table)
                        columns = [k for k in reghere.columns(table) if k != pk]
                        here.execute('CREATE TEMPORARY TABLE _rids (v INTEGER)')  # run_ids we need to merge into res
                        here.executemany('INSERT INTO _rids (v) VALUES (?)',
                                         [(run_id,) for run_id in map_run_id.keys()])
                        rows = here.execute(f'''
                            SELECT {', '.join(columns)} FROM {table}
                            JOIN _rids ON {table}.run_id = _rids.v
                        ''').fetchall()
                        here.execute('DROP TABLE _rids')
                        rows = [list(row) for row in rows]  # make mutable for updating run_id and origin_id
                        i_run_id = columns.index('run_id')
                        for row in rows:
                            row[i_run_id] = map_run_id[row[i_run_id]]  # update run_id to new run_id
                        if 'origin_id' in columns:
                            i_origin_id = columns.index('origin_id')
                            for row in rows:
                                row[i_origin_id] = map_origin_id[row[i_origin_id]]
                        res.executemany(f'''
                            INSERT INTO {table} ({', '.join(columns)})
                            VALUES ({', '.join(['?'] * len(columns))})
                        ''', rows)
        return result
    
    # # # INSPECTION # # #
    @property
    def tables(self):
        '''list of all tables in this registry.'''
        with self.connect() as con:
            # sqlite3 solution (will need others for other engines):
            result = [row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
        return result

    def columns(self, table):
        '''returns list of columns in this table in this registry.'''
        # sqlite3 solution (will need others for other engines):
        return [row[1] for row in self.execute(f'PRAGMA table_info({table})').fetchall()]

    def missing_columns(self, table, columns):
        '''returns list of which of these columns do not yet exist in table in this registry.'''
        exists = set(self.columns(table))
        return [col for col in columns if col not in exists]

    def primary_keys(self, table):
        '''returns list of primary key column names in this table in this registry.'''
        # sqlite3 solution (will need others for other engines):
        return [row[1] for row in self.execute(f'PRAGMA table_info({table})').fetchall() if row[5] == 1]

    def primary_key(self, table):
        '''returns the single primary key column name in this table in this registry,
        crashing if there is not exactly 1 primary key.
        '''
        result = self.primary_keys(table)
        if len(result) != 1:
            raise RegistryContentsError(f"Expected exactly 1 primary key in table {table!r}, but got: {result}")
        return result[0]

    @property
    def sizes(self):
        '''dict of sizes (number of entries) of all tables in this registry.'''
        with self.connect() as con:
            tables = self.tables
            result = {}
            for table in tables:
                count = con.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0]
                result[table] = count
        return result

    # # # DISPLAY # # #
    def __repr__(self):
        return f'{type(self).__name__}(name={self.name!r}, location={self.location!r}, sizes={self.sizes})'


### --------------------- Registerable --------------------- ###

class Registerable(UuidHaver, MetadataHaver):
    '''class whose instances can be registered to a registry, an sql database.'''

    registry_cls = Registry  # class used to create registry from provided "registry" input

    # # # SUBCLASS SHOULD IMPLEMENT # # #
    def registry_params(self):
        '''return flat dict of all input params to use during self.register()
        [Not yet implemented; subclass should implement]
        '''
        raise NotImplementedError(f'{type(self).__name__}.registry_params() required by register().')

    # # # REGISTER AND HELPER METHODS # # #
    def register(self, registry=UNSET, *, metrics=[]):
        '''register this instance and these metrics to the registry.
        returns (registry.register() result, Registry object).
            (registry.register() result is a namedtuple of (run_id, history_id))

        registry: UNSET, None, Registry, or str
            Registry --> use it directly
            else --> pass to self.registry_cls (probably Registry), which probably does:
                UNSET --> use registry implied by DEFAULTS.REGISTRY_LOCATION
                    (if DEFAULTS.REGISTRY_LOCATION=None, use registry_location_default(),
                     which is system-dependent, e.g. "~/.plasmacalcs/pc_registry.db" on Linux.)
                None --> use in-memory registry, equivalent to ":memory:", does not edit any files.
                str --> filepath to registry database file.
        metrics: list of (str or dict) entries
            metrics to register (and possibly compute) for this instance
            str --> compute this metric via self(metric)
            dict --> {key: value} to register directly, without computing via self(key)
            In case of conflict, later values override earlier ones,
                e.g. [{'mykey': 1}, {'mykey': 2}] registers 'mykey' with value 2.
        '''
        if not isinstance(registry, Registry):
            registry = self.registry_cls(registry)
        metadata = self._registry_metadata()
        params = self.registry_params()
        metrics = self._registry_metrics(metrics)
        return (registry.register(metadata, params, metrics), registry)

    def _registry_metadata(self):
        '''return metadata relevant to registering this run.
        This includes all of self.metadata, along with calculator_type and pc_uuid.
        (Uses self.metadata not self.metadata_all; excludes any missing or ATTR_UNSET.)
        If self.pc_uuid does not exist, it will be created via self.get_pc_uuid().
        '''
        metadata = {**self.metadata}  # already excludes ATTR_UNSET
        metadata['calculator_type'] = type(self).__name__
        if 'pc_uuid' not in metadata:
            # generate if needed (or crashes with clear error message if not implemented).
            metadata['pc_uuid'] = self.get_pc_uuid()
        return metadata

    def _registry_metrics(self, metrics):
        '''return dict of metrics' values relevant to registering this run with these metrics.
        Strings in metrics will be converted to {key: self(key)} entries.
            e.g. metrics=['mean_T'] --> result is {'mean_T': self('mean_T')}
        dicts will be used as-is.

        Result will be a dict of {key: (is_pc_quantity, value)} entries,
            with is_pc_quantity telling whether metric was computed via self(key),
            i.e. whether it was a string in the input metrics list.
        '''
        if any(isinstance(m, str) for m in metrics) and getattr(self, 'units', 'si') != 'si':
            raise NotImplementedError('registering non-SI units not yet supported.')
            # ^ technically the code itself can handle it just fine,
            # but this restriction ensures no accidental units confusion.
            # (This can eventually be removed if we record used behavior_attrs somewhere.)
        result = {}
        for i, item in enumerate(metrics):  # TODO: progress updates if slow
            if isinstance(item, str):
                value = self(item)
                if value.size == 1:
                    value = value.item()
                else:
                    errmsg = f'Metrics must be scalars; {item!r} has shape {value.shape}'
                    raise DimensionalityError(errmsg)
                result[item] = (True, value)
            elif isinstance(item, dict):
                for key, value in item.items():
                    result[key] = (False, value)
            else:
                raise InputError(f'Metrics must be str or dict, got {type(item)} at index={i}.')
        return result

    # [TODO] registry_run_id() which returns run_id of this run within the registry or None.
