"""
File Purpose: Registry and Registerable class
"""
import collections
import uuid
import re

from .metadata_haver import MetadataHaver, UuidHaver
from .registry_tools import (
    registry_location, registry_schema_sql, registry_name_default,
)
from ..errors import (
    InputError, InputMissingError, InputConflictError,
    DimensionalityError,
    RegistryConflictError, RegistryContentsError,
)
from ..tools import (
    UNSET, simple_property, using_attrs,
    datetime_now, _PlasmaCalcs_version,
    SqlBase,
)
from ..defaults import DEFAULTS


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

class Registry(SqlBase):
    '''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_<short_uuid>' as name,
            filling <short_uuid> with the first 8 chars of this registry's source_uuid.
        str --> use this for registry name. Crash if registry already has non-default stored name.
        Note that registry name is stored in registry: with self.connect() as con:
            name == con.execute('SELECT name FROM sources WHERE source_id = 0').fetchone()[0]
    username: UNSET, None, or str
        UNSET --> use DEFAULTS.REGISTRY_USERNAME, which is None by default.
        None --> no known username for the current user. This is fine for local databases.
        str --> username of the current user. Required when working with online databases.
        Expected to be immutable within this Registry object;
            changing self.username after initialization leads to unspecified behavior.
    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:
        users: table of users (e.g. "who created this entry originally")
            user_id (PK): unique id for each user in this database.
                in local database, user_id=0 is reserved for unspecified user, username="__self__".
                in online database, user_id=0 is not allowed and all usernames must be specified.
            username: uniquely identifies this user across all databases.
        sources: table of sources (e.g. "which database did this entry come from")
            source_id (PK): unique id for each source in this database.
                source_id=0 is reserved for "this database".
            name: source name. Duplicates across registries are allowed but discouraged.
                default sql name is "__here__"; python updates to "_unnamed_{start_of_uuid}".
            source_uuid: uniquely identifies this source's database.
                default sql value is a bunch of 0s; python updates to an actual random uuid.
            creator_user_id (FK): user_id of the user who first created this source's database.
        runs: table of runs (or observations or other data source)
            run_id (PK): unique id for each run in this database.
            user_id (FK): tells which user last registered this run (see users table above)
            source_id (FK): tells which source this run came from (see below)
            source_run_id: tells run_id of this entry in its original registry (NULL when source_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
            user_id (FK): user_id telling which user made this register() call.
            source_id (FK): source_id telling which source 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()
    
    Merging databases will alter run_id for runs, such that run_id throughout the result is unique.
    Merged databases will include multiple entries in sources table; unmerged will have only source_id=0.

    Online databases will alter run_id and source_id as needed.
    '''
    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, username=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 = name
        if username is UNSET:
            username = DEFAULTS.REGISTRY_USERNAME
        if (username is None) and (not self.is_local):
            raise InputMissingError('username must be provided for non-local database')
        self.username = username

    # # # SQL CONNECTION AND CROSS-ENGINE COMPATIBILITY # # #
    def schema_exists(self, *, _con=None):
        '''return whether self.location points to a database with registry schema.
        Here, just checks whether "runs", "params", and "metrics" tables exist.
        (This method never initializes schema, regardless of self.connect_can_init)

        _con: None or open connection object
            for efficiency, can provide connection object if already created.
        '''
        if not self._location_exists():
            return False
        with using_attrs(self, connect_can_init=False):
            tables = self.get_tables(_con=_con)
        return all(table in tables for table in ('runs', 'params', 'metrics'))

    connect_can_init = simple_property('_connect_can_init', default=True,
        doc='''whether self.connect() is allowed to initialize things like schema, source name, etc.
        If can init, still only initializes if appropriate, e.g. checks self.schema_exists().
        Default True, i.e. self.connect() initializes schema if needed.''')

    connect_init_only_once = simple_property('_connect_init_only_once', default=True,
        doc='''whether to set self.connect_can_init=False after the first initialization attempt,
        i.e. after the first self.connect() call where self.connect_can_init was True.''')

    def connect(self):
        '''return a connection to this registry's database.

        if self.connect_can_init, also runs some initialization routines as needed:
            - if the database doesn't exist yet (and not online database), create it.
            - if the schema hasn't been initialized yet, initialize it. See: registry_schema_sql()
            - if the database name (sources source_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().
        '''
        con = super().connect()
        with con:
            if self.connect_can_init:
                if not self.schema_exists(_con=con):
                    schema_sql = registry_schema_sql(engine=self.engine_backend)
                    con.executescript(schema_sql)
                self._init_user(con)
                self._init_source(con)
                if self.connect_init_only_once:
                    self.connect_can_init = False
        return con

    def _init_user(self, con):
        '''initialize self.user_id, and users table as appropriate.
        con must be an open connection.
        '''
        if self.username is None:
            self.user_id = 0
        else:
            row = con.execute('SELECT user_id FROM users WHERE username = ?', (self.username,)).fetchone()
            if row is None:
                self.user_id = con.execute('INSERT INTO users (username) VALUES (?) RETURNING user_id',
                                           (self.username,)).fetchone()[0]
            else:
                self.user_id = row[0]

    def _init_source(self, con):
        '''initialize self.name, sources.name, and sources.source_uuid as appropriate.
        con must be an open connection.
        '''
        sourcerow = con.execute('SELECT name, source_uuid FROM sources WHERE source_id = 0').fetchone()
        name, uuid_ = sourcerow
        if (name == '__here__') != (uuid_ == '0'*32):
            errmsg = ('Expected sources.name="__here__" if and only if sources.source_uuid="0"*32.\n'
                      f'Got sources.name={name!r}, source_uuid={uuid!r}.')
            raise RegistryContentsError(errmsg)
        if name == '__here__':
            # new, uninitialized database. Initialize name and source_uuid.
            new_uuid = uuid.uuid4().hex
            name = registry_name_default(self.location, self.name, uuid_=new_uuid)
            con.execute('UPDATE sources SET name = ?, source_uuid = ? WHERE source_id = 0',
                        (name, new_uuid))
        elif isinstance(self.name, str):  # non-default name provided here
            if re.fullmatch(r'_unnamed_[0-9a-f]{8}', name):
                # working with pre-initialized database that has a default name; update it.
                con.execute('UPDATE sources SET name = ? WHERE source_id = 0', (self.name,))
            elif name != self.name:
                errmsg = (f'Provided name={self.name!r} does not match database name={name!r}.\n'
                          '(Database name determined by: SELECT name FROM sources WHERE source_id=0)')
                raise RegistryConflictError(errmsg)
        self.name = name
        # [TODO] should self.name instead update dynamically if a different connection updates sources.name?

    user_id = simple_property('_user_id',
        doc='''id of user associated with this Registry object, based on self.username.
        0 corresponds to unspecified user (only allowed for local databases).
        Set only once, during initialization.''')
    @user_id.getter
    def user_id(self):
        '''returns self._user_id if it exists, else crash with helpful error.'''
        if hasattr(self, '_user_id'):
            return self._user_id
        else:
            errmsg = ('requested self.user_id but it currently does not exist!\n'
                      'user_id is normally set during first call to self.connect().\n'
                      'This crash probably means things were not intialized properly.')
            raise RegistryContentsError(errmsg)

    # # # 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
            run_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 = ?
            ''', (run_id,)).fetchall()
        self._assert_all_params_match(sanitized_params, fetched_param_rows, run_id=run_id, uuid=uuid)
        return run_id

    def _assert_all_params_match(self, sanitized_params, fetched_param_rows, *, run_id=None, uuid=None):
        '''check that all sanitized_params match fetched_param_rows from database.
        Crash with RegistryConflictError if params don't all match.
        run_id and uuid go into error message if crash occurs.
        '''
        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 = ('user_id', 'source_id', 'source_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 source_id, source_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:
            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:
                con.execute(f'ALTER TABLE runs ADD COLUMN "{col}" TEXT')
            # # convert to str to ensure consistency with str conversion in _register_update_run()
            metadata_vals = {**metadata}
            text_metadata_cols = self.text_columns('runs')
            for k in set(text_metadata_cols).intersection(metadata.keys()):
                metadata_vals[k] = str(metadata_vals[k])
            values = {'source_id': 0,  # all runs registered directly get source_id = 0
                      'user_id': self.user_id,
                      **metadata_vals,
                      'registered_at_first': now, 'registered_at': now}
            run_id = con.execute(f'''
                INSERT INTO runs ({', '.join([f'"{k}"' for k in values])})
                VALUES ({', '.join([f':{k}' for k in values])})
                RETURNING run_id
            ''', values).fetchone()[0]
            # insert into params table
            for name, value in sanitized_params.items():
                if isinstance(value, str):
                    con.execute('''
                        INSERT INTO params (run_id, name, value_text)
                        VALUES (?, ?, ?)
                    ''', (run_id, name, value))
                else:
                    con.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():
                con.execute('''
                    INSERT INTO metrics (run_id, name, value, is_pc_quantity)
                    VALUES (?, ?, ?, ?)
                ''', (run_id, name, value, is_pc_quantity))
            # insert into register_history table
            history = {
                'source_id': 0,
                'run_id': run_id,
                'user_id': self.user_id,
                'entered_at': now,
                'pc_version': _PlasmaCalcs_version(),
                'updated_metadata': ', '.join(metadata.keys()),  # all were "updated"; this is a new run.
                'updated_metrics': ', '.join(metrics.keys()),  # all were "updated"; this is a new run.
            }
            history_id = con.execute('''
                INSERT INTO register_history (source_id, run_id, user_id, entered_at, pc_version,
                                              updated_metadata, updated_metrics)
                VALUES (:source_id, :run_id, :user_id, :entered_at, :pc_version,
                        :updated_metadata, :updated_metrics)
                RETURNING history_id
            ''', history).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:
            now = datetime_now(tz='utc', sep='T')
            # update user_id
            user_id = con.execute('''
                UPDATE runs SET user_id = :suid WHERE run_id = :run_id AND user_id IS DISTINCT FROM :suid
                RETURNING user_id
            ''', dict(suid=self.user_id, run_id=run_id)).fetchone()
            updated_user_id = user_id is not None
            # update metadata table
            updated_metadata = []
            missing_metakeys = self.missing_columns('runs', metadata.keys())
            text_metadata_cols = self.text_columns('runs')
            for key, value in metadata.items():
                if key in text_metadata_cols or key in missing_metakeys:
                    value = str(value)   # <-- convert to string for TEXT type.
                if key in missing_metakeys:  # key is missing --> add column
                    con.execute(f'ALTER TABLE runs ADD COLUMN "{key}" TEXT')
                    updated = True
                else:  # key exists --> update value if it changed
                    _run_id = con.execute(f'''
                        UPDATE runs
                        SET "{key}" = :value
                        WHERE run_id = :run_id
                            AND "{key}" IS DISTINCT FROM :value
                        RETURNING run_id   -- returns something if value actually changed
                    ''', dict(value=value, run_id=run_id)).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():
                metric_info = {
                    'run_id': run_id,
                    'name': name,
                    'value': value,
                    'is_pc_quantity': is_pc_quantity,
                }
                # if (run_id, name) is missing, insert it, else update value (if it changed)
                metric_exists = con.execute('''
                    SELECT metric_id from metrics
                    WHERE run_id = :run_id AND name = :name
                    ''', metric_info).fetchone()
                metric_missing = (metric_exists is None)
                if metric_missing:  # (run_id, name) is missing --> insert
                    con.execute('''
                        INSERT INTO metrics (run_id, name, value, is_pc_quantity)
                        VALUES (:run_id, :name, :value, :is_pc_quantity)
                    ''', metric_info)
                    updated = True
                else:  # (run_id, name) exists --> update (value, is_pc_quantity) if it changed
                    _metric_id = con.execute('''
                        UPDATE metrics
                        SET value = :value, is_pc_quantity = :is_pc_quantity
                        WHERE run_id = :run_id AND name = :name
                            AND (   -- check if value actually changed.
                                 value IS DISTINCT FROM :value
                                 OR is_pc_quantity IS DISTINCT FROM :is_pc_quantity
                                )
                        RETURNING metric_id   -- returns something if value actually changed
                    ''', metric_info).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_user_id or updated_metadata or updated_metrics:
                update_info = {
                    'source_id': 0,
                    'run_id': run_id,
                    'user_id': self.user_id,
                    'registered_at': now,
                    'entered_at': now,
                    'pc_version': _PlasmaCalcs_version(),
                    'updated_metadata': ', '.join(updated_metadata),
                    'updated_metrics': ', '.join(updated_metrics),
                }
                # update registered_at
                con.execute('''
                    UPDATE runs
                    SET registered_at = :registered_at
                    WHERE run_id = :run_id
                ''', update_info)
                # update register_history
                history_id = con.execute('''
                    INSERT INTO register_history (source_id, run_id, user_id, entered_at, pc_version,
                                                  updated_metadata, updated_metrics)
                    VALUES (:source_id, :run_id, :user_id, :entered_at, :pc_version,
                            :updated_metadata, :updated_metrics)
                    RETURNING history_id
                ''', update_info).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 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 user_id and source_id appropriately.
                    # (we also need to add source_run_id to each run)
                    # so we iterate through runs, copying them one at a time.
                    # first, iterate through users:
                    users_here = here.execute('SELECT user_id, username FROM users').fetchall()
                    map_user_id = {}
                    for user_id, username in users_here:
                        # check if username exists already; use corresponding user_id if it does.
                        new_user_id = res.execute('SELECT user_id FROM users WHERE username = ?',
                                                  (username,)).fetchone()
                        if new_user_id is None:
                            new_user_id = res.execute('''
                                INSERT INTO users (username)
                                VALUES (?)
                                RETURNING user_id
                            ''', (username,)
                            ).fetchone()
                        map_user_id[user_id] = new_user_id[0]
                    # next, iterate through sources:
                    sources_here = here.execute('''
                        SELECT source_id, name, source_uuid, creator_user_id FROM sources
                    ''').fetchall()
                    map_source_id = {}
                    for source_id, source_name, source_uuid, creator_user_id in sources_here:
                        new_source_id = res.execute('SELECT source_id FROM sources WHERE source_uuid = ?',
                                                    (source_uuid,)).fetchone()
                        if new_source_id is None:
                            new_source_id = res.execute('''
                                INSERT INTO sources (name, source_uuid, creator_user_id)
                                VALUES (?, ?, ?)
                                RETURNING source_id
                            ''', (source_name, source_uuid, map_user_id[creator_user_id])
                            ).fetchone()
                        map_source_id[source_id] = new_source_id[0]
                    # next, iterate through runs:  # [TODO] any way to bulk insert runs?
                    map_run_id = {}
                    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']
                        run_dict['user_id'] = map_user_id[run_dict['user_id']]
                        run_dict['source_id'] = map_source_id[run_dict['source_id']]
                        run_dict['source_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([f'"{k}"' for k in run_dict])})
                                VALUES ({', '.join([f':{k}' for k in run_dict])})
                                RETURNING run_id
                            ''', run_dict).fetchone()[0]
                            map_run_id[old_run_id] = new_run_id
                        # else, don't add to map_run_id; not going to alter result's existing values for this run.
                    # 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 source_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 'user_id' in columns:
                            i_user_id = columns.index('user_id')
                            for row in rows:
                                row[i_user_id] = map_user_id[row[i_user_id]]
                        if 'source_id' in columns:
                            i_source_id = columns.index('source_id')
                            for row in rows:
                                row[i_source_id] = map_source_id[row[i_source_id]]
                        res.executemany(f'''
                            INSERT INTO {table} ({', '.join(columns)})
                            VALUES ({', '.join(['?'] * len(columns))})
                        ''', rows)
        return result

    # # # DISPLAY # # #
    def _repr_contents(self):
        '''list of contents for self.__repr__'''
        result = [f'name={self.name!r}']
        result.extend(super()._repr_contents())
        return result


### --------------------- 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=[], username=UNSET):
        '''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.
        username: UNSET, None, or str
            Username associated with this operation.
            UNSET --> get value from DEFAULTS.REGISTRY_USERNAME (default None).
            None --> no username provided. Only allowed for local databases.
            str --> username associated with this operation.
                    Also, checks for consistency with registry.username if `registry` is a Registry object.
        '''
        if isinstance(username, str) and isinstance(registry, Registry):
            if registry.username != username:
                errmsg = f"registry.username={registry.username!r} disagrees with provided username={username!r}"
                raise InputConflictError(errmsg)
                # [TODO] if registry.username is None, temporarily update it to the username provided here?
        if not isinstance(registry, Registry):
            registry = self.registry_cls(registry, username=username)
        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.
