# Copyright (c) 2001, 2002, 2003, 2004 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh@intevation.de>
# Jan-Oliver Wagner <jan@intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.

__version__ = "$Revision: 2102 $"

import os
from tempfile import mktemp
import weakref

from messages import MAPS_CHANGED, EXTENSIONS_CHANGED, FILENAME_CHANGED, \
     MAP_LAYERS_CHANGED, MAP_PROJECTION_CHANGED, \
     LAYER_CHANGED, LAYER_PROJECTION_CHANGED, LAYER_VISIBILITY_CHANGED,\
     EXTENSION_CHANGED, EXTENSION_OBJECTS_CHANGED, CHANGED, \
     TABLE_REMOVED, DBCONN_ADDED, DBCONN_REMOVED

from Thuban import _

from base import TitledObject, Modifiable
from map import Map
from data import ShapefileStore
from table import DBFTable
import postgisdb

from transientdb import TransientDatabase, AutoTransientTable

class AutoRemoveFile:

    """Remove a file once all references go away."""

    def __init__(self, filename, tempdir = None):
        """Initialize the AutoRemoveFile

        Parameters:
           filename -- The name of the file to remove in __del__
           tempdir -- Another object simple stored as an instance variable.

        As the name suggests the tempdir parameter is intended for a
        temporary directory the file might be located in. The intended
        use is that it's an instance of AutoRemoveDir.
        """
        self.filename = filename
        self.tempdir = tempdir

    def __del__(self, remove = os.remove):
        remove(self.filename)

class AutoRemoveDir:

    """Remove a directory once all references go away

    The intended use of this class together with AutoRemoveFile is for
    temporary directories and files containd therein. An AutoRemoveDir
    should be instantiated for the directory and passed as the tempdir
    parameter to every AutoRemoveFile instance created for files in the
    directory. An AutoRemoveFile shold be instantiated for every file
    created in the directory so that the directory is automatically
    removed once the last file is removed.
    """

    def __init__(self, filename):
        """Initialize the AutoRemoveDir

        The parameter is the name of the directory.
        """
        self.filename = filename

    def __del__(self, rmdir = os.rmdir):
        rmdir(self.filename)


# WeakKey dictionary mapping objects like the transient_db to
# AutoRemoveDir or AutoRemoveFile instances to make sure that the
# temporary files and the directory are deleted but not before the
# objects that use them go away.
auto_remover = weakref.WeakKeyDictionary()

class Session(TitledObject, Modifiable):

    """A complete session.

    A Session consists of arbitrary numbers of maps, tables and extensions

    Session objects send the following events:

        TITLE_CHANGED -- The title has changed. Parameters: the session.

        FILENAME_CHANGED -- The filename has changed. No parameters.

        MAPS_CHANGED -- Maps were added, removed.

        EXTENSIONS_CHANGED -- Extensions were added, removed.

        MAP_LAYERS_CHANGED -- Same as the map's event of the same name.
                          It's simply resent from the session to make
                          subscriptions easier.

        CHANGED -- Generic changed event. Parameters: the session. The
                   event is always issued when any other changed event
                   is issused. This is useful for code that needs to be
                   notified whenever something in the session has
                   changed but it's too cumbersome or error-prone to
                   subscribe to all the individual events.
    """

    # message channels that have to be forwarded from maps contained in
    # the session.
    forwarded_channels = (
        # generic channels
        CHANGED,

        # map specific channels
        MAP_PROJECTION_CHANGED,
        MAP_LAYERS_CHANGED,

        # layer channels forwarded by the map
        LAYER_PROJECTION_CHANGED,
        LAYER_CHANGED,
        LAYER_VISIBILITY_CHANGED,

        # channels forwarded by an extension
        EXTENSION_CHANGED,
        EXTENSION_OBJECTS_CHANGED)

    def __init__(self, title):
        TitledObject.__init__(self, title)
        Modifiable.__init__(self)
        self.filename = None
        self.maps = []
        self.tables = []
        self.shapestores = []
        self.extensions = []
        self.db_connections = []
        self.temp_dir = None
        self.transient_db = None

    def changed(self, channel = None, *args):
        """Like the inherited version but issue a CHANGED message as well.

        The CHANGED message is only issued if channel given is a
        different channel than CHANGED.
        """
        Modifiable.changed(self, channel, *args)
        if channel != CHANGED:
            self.issue(CHANGED, self)

    def SetFilename(self, filename):
        self.filename = filename
        self.changed(FILENAME_CHANGED)

    def Maps(self):
        return self.maps

    def HasMaps(self):
        return len(self.maps) > 0

    def AddMap(self, map):
        self.maps.append(map)
        for channel in self.forwarded_channels:
            map.Subscribe(channel, self.forward, channel)
        self.changed(MAPS_CHANGED)

    def RemoveMap(self, map):
        for channel in self.forwarded_channels:
            map.Unsubscribe(channel, self.forward, channel)
        self.maps.remove(map)
        self.changed(MAPS_CHANGED)
        map.Destroy()

    def Extensions(self):
        return self.extensions

    def HasExtensions(self):
        return len(self.extensions) > 0

    def AddExtension(self, extension):
        self.extensions.append(extension)
        for channel in self.forwarded_channels:
            extension.Subscribe(channel, self.forward, channel)
        self.changed(EXTENSIONS_CHANGED)

    def ShapeStores(self):
        """Return a list of all ShapeStore objects open in the session"""
        return [store() for store in self.shapestores]

    def _add_shapestore(self, store):
        """Internal: Add the shapestore to the list of shapestores"""
        self.shapestores.append(weakref.ref(store,
                                            self._clean_weak_store_refs))

    def _clean_weak_store_refs(self, weakref):
        """Internal: Remove the weakref from the shapestores list"""
        self.shapestores = [store for store in self.shapestores
                                  if store is not weakref]

    def Tables(self):
        """Return a list of all table objects open in the session

        The list includes all tables that are indirectly opened through
        shape stores and the tables that have been opened explicitly.
        """
        tables = self.tables[:]
        ids = {}
        for t in tables:
            ids[id(t)] = 1
        for store in self.ShapeStores():
            t = store.Table()
            if id(t) not in ids:
                ids[id(t)] = 1
                tables.append(t)
        return tables

    def UnreferencedTables(self):
        """Return the tables that are not referenced by other data sources"""
        known = {}
        for table in self.tables:
            known[id(table)] = 0
        for table in self.tables + self.ShapeStores():
            for dep in table.Dependencies():
                known[id(dep)] = 1
        return [table for table in self.tables if known[id(table)] == 0]

    def AddTable(self, table):
        """Add the table to the session

        All tables associated with the session that are not implicitly
        created by the OpenShapefile method (and maybe other Open*
        methods in the future) have to be passed to this method to make
        sure the session knows about it. The session keeps a reference
        to the table. Only tables managed by the session in this way
        should be used for layers contained in one of the session's
        maps.

        The table parameter may be any object implementing the table
        interface. If it's not already one of the transient tables
        instantiate an AutoTransientTable with it and use that instead
        of the original table (note that the AutoTransientTable keeps a
        reference to the original table).

        Return the table object actually used by the session.
        """
        if not hasattr(table, "transient_table"):
            transient_table = AutoTransientTable(self.TransientDB(), table)
        else:
            transient_table = table
        self.tables.append(transient_table)
        self.changed()
        return transient_table

    def RemoveTable(self, table):
        """Remove the table from the session.

        The table object must be a table object previously returned by
        the AddTable method. If the table is not part of the session
        raise a ValueError.

        Issue a TABLE_REMOVED message after the table has been removed.
        The message has the removed table as the single parameter.
        """
        tables = [t for t in self.tables if t is not table]
        if len(tables) == len(self.tables):
            raise ValueError
        self.tables = tables
        self.changed(TABLE_REMOVED, table)

    def DataContainers(self):
        """Return all data containers, i.e. shapestores and tables"""
        return self.tables + self.ShapeStores()

    def OpenTableFile(self, filename):
        """Open the table file filename and return the table object.

        The filename argument must be the name of a DBF file.
        """
        return self.AddTable(DBFTable(filename))

    def temp_directory(self):
        """
        Return the name of the directory for session specific temporary files

        Create the directory if it doesn't exist yet.
        """
        if self.temp_dir is None:
            temp_dir = mktemp()
            os.mkdir(temp_dir, 0700)
            self.temp_dir = temp_dir
            self.temp_dir_remover = AutoRemoveDir(self.temp_dir)
        return self.temp_dir

    def OpenShapefile(self, filename):
        """Return a shapefile store object for the data in the given file"""
        store = ShapefileStore(self, filename)
        self._add_shapestore(store)
        return store

    def AddShapeStore(self, shapestore):
        """Add the shapestore to the session.

        The session only holds a weak reference to the shapestore, so it
        will automatically be removed from the session when the last
        reference goes away.
        """
        self._add_shapestore(shapestore)
        return shapestore

    def TransientDB(self):
        if self.transient_db is None:
            filename = os.path.join(self.temp_directory(), "transientdb")
            self.transient_db = TransientDatabase(filename)
            #print self.temp_dir_remover
            auto_remover[self.transient_db] = AutoRemoveFile(filename,
                                                        self.temp_dir_remover)
        return self.transient_db

    def AddDBConnection(self, dbconn):
        """Add the database connection dbconn to the session

        The argument should be an instance of PostGISConnection.
        """
        self.db_connections.append(dbconn)
        self.changed(DBCONN_ADDED)

    def DBConnections(self):
        """
        Return a list of all database connections registered with the session
        """
        return self.db_connections

    def HasDBConnections(self):
        """Return whether the session has open database connections"""
        return bool(self.db_connections)

    def CanRemoveDBConnection(self, dbconn):
        """Return whether the database connections dbconn can be removed

        If can be removed if none of the shapestores or tables in the
        session references it.
        """
        for store in self.ShapeStores():
            if (isinstance(store, postgisdb.PostGISShapeStore)
                and store.db is dbconn):
                return 0
        for table in self.Tables():
            if (isinstance(table, postgisdb.PostGISTable)
                and table.db is dbconn):
                return 0
        return 1

    def RemoveDBConnection(self, dbconn):
        """Remove the database connection from the session

        The parameter must be a connection that was registered
        previously by a AddDBConnection() call.
        """
        if self.CanRemoveDBConnection(dbconn):
            remaining = [c for c in self.db_connections if c is not dbconn]
            if len(remaining) < len(self.db_connections):
                self.db_connections = remaining
                self.changed(DBCONN_REMOVED)
            else:
                raise ValueError("DBConection %r is not registered"
                                 " with session %r" % (dbconn, self))
        else:
            raise ValueError("DBConnection %r is still in use" % (dbconn,))

    def OpenDBShapeStore(self, db, tablename, id_column = None,
                         geometry_column = None):
        """Create and return a shapstore for a table in the database

        The db parameter must be a database connection previously passed
        to AddDBConnection().
        """
        store = postgisdb.PostGISShapeStore(db, tablename,
                                            id_column = id_column,
                                            geometry_column = geometry_column)
        self._add_shapestore(store)
        return store

    def Destroy(self):
        for map in self.maps:
            map.Destroy()
        self.maps = []
        self.tables = []
        Modifiable.Destroy(self)

        # Close the transient DB explicitly so that it removes any
        # journal files from the temporary directory
        if self.transient_db is not None:
            self.transient_db.close()

    def forward(self, *args):
        """Reissue events.

        If the channel the event is forwarded to is a changed-channel
        that is not the CHANGED channel issue CHANGED as well. An
        channel is considered to be a changed-channel if it's name ends
        with 'CHANGED'.
        """
        if len(args) > 1:
            args = (args[-1],) + args[:-1]
        apply(self.issue, args)
        channel = args[0]
        # It's a bit of a kludge to rely on the channel name for this.
        if channel.endswith("CHANGED") and channel != CHANGED:
            self.issue(CHANGED, self)

    def WasModified(self):
        """Return true if the session or one of the maps was modified"""
        if self.modified:
            return 1
        else:
            for map in self.maps:
                if map.WasModified():
                    return 1
        return 0

    def UnsetModified(self):
        """Unset the modified flag of the session and the maps"""
        Modifiable.UnsetModified(self)
        for map in self.maps:
            map.UnsetModified()

    def TreeInfo(self):
        items = []
        if self.filename is None:
            items.append(_("Filename:"))
        else:
            items.append(_("Filename: %s") % self.filename)

        if self.WasModified():
            items.append(_("Modified"))
        else:
            items.append(_("Unmodified"))

        items.extend(self.maps)
        items.extend(self.extensions)

        return (_("Session: %s") % self.title, items)


def create_empty_session():
    """Return an empty session useful as a starting point"""
    import os
    session = Session(_('unnamed session'))
    session.SetFilename(None)
    session.AddMap(Map(_('unnamed map')))
    session.UnsetModified()
    return session
