require "meta_db/version"

require "indented_io"

require 'pg'
require 'yaml'

require 'meta_db/connection.rb'
require 'meta_db/db_object.rb'

module MetaDb
  # TODO
  def self.load_yaml(file)
    YAML.load_file(file)
  end

  def self.save_yaml(db, file, pretty: true)
    File.open(file, 'w') do |f|
      arg = pretty ? { :indentation => 3 } : {}
      f.write(YAML.dump(db, arg))
    end
  end

  def self.load_marshal(file)
    File.open(file) { |f| Marshal.load(f) }
  end

  def self.save_marshal(db, file)
    File.open(file, 'w') { |f| Marshal.dump(db, f) }
  end

  # The options argument is a hash as PG::Connection options. :host, :port,
  # :dbname, :user, and :password are some of the most used
  def self.load_pg_conn(options)
    PostgresConnection.open(options) { |conn| load_conn(conn) }
  end

private
  # Connect to the given database and return a MetaDb::Db object. If host is
  # nil, a socket is used to connect to the database
  #
  # The connection object should support #name and #exec
  def self.load_conn(conn)
    # TODO: Start transaction

    # Build database
    r = conn.select %(
      select  usename::varchar
      from    pg_database d
      join    pg_user u on u.usesysid = d.datdba
      where   d.datname = '#{conn.database}'
    )
    r.size == 1 or raise "Internal error"
    db = MetaDb::Database.new(conn.database, r.value)

    # Build schemas
    conn.select(%(
      select  schema_name::text as name,
              schema_owner::text as owner
      from    information_schema.schemata
      where   schema_name !~ '^pg_' AND schema_name <> 'information_schema' 
    )).each_hash { |row|
      row[:database] = db
      MetaDb::Schema.init(row)
    }

    # Build tables
    conn.select(%(
      select  table_schema::text as schema,
              table_name::text as name,
              table_type::text as type,
              is_insertable_into = 'YES' as "insertable?",
              is_typed = 'YES' as "typed?"
      from    information_schema.tables
      where   table_schema !~ '^pg_' AND table_schema <> 'information_schema'
    )).each_hash { |row|
      row[:schema] = db.schemas[row[:schema]]
      klass = (row[:type] == 'VIEW' ? MetaDb::View : MetaDb::Table)
      klass.init(row)
    }

    # Build columns
    conn.select(%(
      select  table_schema::text as schema,
              table_name::text as table,
              column_name::text as name,
              ordinal_position as ordinal,
              data_type::text as type,
              column_default as default,
              is_identity = 'YES' as "identity?",
              is_generated = 'ALWAYS' as "generated?",
              is_nullable = 'YES' as "nullable?",
              is_updatable = 'YES' as "updatable?"
      from    information_schema.columns
      where   table_schema !~ '^pg_' AND table_schema <> 'information_schema'
    )).each_hash { |row|
      row[:table] = db.schemas[row[:schema]].tables[row[:table]]
      MetaDb::Column.init(row)
    }

    # Build simple constraints
    conn.select(%(
      select  c.table_schema::text as schema,
              c.table_name::text as table,
              c.constraint_type::text,
              c.constraint_name::text as name,
              cc.check_clause as expression,
              (
                select  array_agg(column_name::text)
                from    information_schema.constraint_column_usage ccu
                where   ccu.table_schema = c.table_schema
                and     ccu.table_name = c.table_name
                and     ccu.constraint_schema = c.constraint_schema
                and     ccu.constraint_name = c.constraint_name
              ) as columns
      from    information_schema.table_constraints c
      left join information_schema.check_constraints cc
                on  cc.constraint_schema = c.table_schema and
                    cc.constraint_name = c.constraint_name
      where   c.table_schema !~ '^pg_' AND c.table_schema <> 'information_schema'
      and     c.constraint_type in ('PRIMARY KEY', 'UNIQUE', 'CHECK')
    )).each_hash { |row|
      row[:table] = db.schemas[row[:schema]].tables[row[:table]]
      row[:columns] = lookup_columns(row[:table], row[:columns] || [])
      case row[:constraint_type]
        when "PRIMARY KEY"; MetaDb::PrimaryKeyConstraint.init(row)
        when "UNIQUE"; MetaDb::UniqueConstraint.init(row)
        when "CHECK"; MetaDb::CheckConstraint.init(row)
      else
        raise "Oops"
      end
    }

    # Build referential constraints
    #
    # Referential constraints has to be initialized after unique constraints
    #
    # The GROUP BY is necessary because we re-assign constraints from schema to
    # table. This requires joining key_column_usage again to get the name of
    # the referenced table and that yields a row for each column in the unique
    # key (TODO: Can this be omitted?)
    #
    conn.select(%(
      select  rc.constraint_schema::text as schema,
              rc.constraint_name::text as name,
              cu_refing.table_schema::text as "referencing_schema",
              cu_refing.table_name::text as "referencing_table",
              (
                select  array_agg(column_name::text order by ordinal_position)
                from    information_schema.key_column_usage kcu
                where   kcu.constraint_name = rc.constraint_name
              ) as "referencing_columns",
              cu_refed.table_schema::text as referenced_schema,
              cu_refed.table_name::text as referenced_table,
              cu_refed.constraint_name::text as referenced_constraint
      from    information_schema.referential_constraints rc
      join    information_schema.key_column_usage cu_refing 
              on  cu_refing.constraint_schema = rc.constraint_schema
                  and cu_refing.constraint_name = rc.constraint_name
      join    information_schema.key_column_usage cu_refed 
              on  cu_refed.constraint_schema = rc.unique_constraint_schema
                  and cu_refed.constraint_name = rc.unique_constraint_name
      where   cu_refing.table_schema !~ '^pg_' AND cu_refing.table_schema <> 'information_schema'
      group by
              rc.constraint_schema,
              rc.constraint_name,
              cu_refing.table_schema,
              cu_refing.table_name,
              cu_refed.table_schema,
              cu_refed.table_name,
              cu_refed.constraint_name
    )).each_hash { |row|
      row[:schema] = db.schemas[row[:schema]]
      row[:referencing_table] = row[:schema].tables[row[:referencing_table]]
      row[:referencing_columns] = lookup_columns(row[:referencing_table], row[:referencing_columns])
      row[:referenced_constraint] = 
          db.schemas[row[:referenced_schema]] \
          .tables[row[:referenced_table]] \
          .constraints[row[:referenced_constraint]]
      MetaDb::ReferentialConstraint.init(row)
    }

    # Build functions and procedures
    conn.select(%(
      select  s.nspname::text as "schema",
              pg_get_userbyid(p.proowner)::text as "owner",
              format('%I(%s)', p.proname, oidvectortypes(p.proargtypes)) 
                || ': ' 
                || format_type(p.prorettype, null) as "name",
              case format_type(p.prorettype, null) 
                when 'void' then 'procedure' 
                else 'function' 
              end as "kind",
              case 
                when prosecdef then 'definer' 
                else 'invoker' 
              end as "security"
      from    pg_proc p
      join    pg_namespace s on (p.pronamespace = s.oid)
      where   s.nspname !~ '^pg_' AND s.nspname <> 'information_schema'
    )).each_hash { |row|
      row[:schema] = db.schemas[row[:schema]]
      klass = (row[:kind] == 'function' ? MetaDb::Function : MetaDb::Procedure)
      klass.init(row)
    }

    # Build user-defined triggers
    conn.select(%(
      select  n.nspname::text as "schema", 
              c.relname::text as "table", 
              t.tgname::text as "name",
              fn.nspname::text as "function_schema",
              format('%I(%s)', p.proname::text, oidvectortypes(p.proargtypes)) 
                || ': ' 
                || format_type(p.prorettype, null) as "function_name",
              case when (tgtype::int::bit(7) & b'0000001')::int = 0 then 'stmt' else 'row' end as "level",
              coalesce(
                case when (tgtype::int::bit(7) & b'0000010')::int = 0 then null else 'before' end,
                case when (tgtype::int::bit(7) & b'0000010')::int = 0 then 'after' else null end,
                case when (tgtype::int::bit(7) & b'1000000')::int = 0 then null else 'instead' end,
                ''
              )::text as "timing", 
                (case when (tgtype::int::bit(7) & b'0000100')::int = 0 then '' else ' insert' end) ||
                (case when (tgtype::int::bit(7) & b'0001000')::int = 0 then '' else ' delete' end) ||
                (case when (tgtype::int::bit(7) & b'0010000')::int = 0 then '' else ' update' end) ||
                (case when (tgtype::int::bit(7) & b'0100000')::int = 0 then '' else ' truncate' end)
              as "events"
      from    pg_trigger t
      join    pg_proc p on t.tgfoid = p.oid
      join    pg_class c on c.oid = t.tgrelid
      join    pg_namespace n on n.oid = c.relnamespace
      join    pg_namespace fn on fn.oid = p.pronamespace
      where   not t.tgisinternal
    )).each_hash { |row|
      schema = db.schemas[row[:schema]]
      row[:table] = schema.tables[row[:table]]
      function_schema = db.schemas[row[:function_schema]]
      row[:function] = function_schema.functions[row[:function_name]]
      MetaDb::Trigger.init(row)
    }

    # Return database object
    db
  end

  def self.lookup_columns(table, column_names)
    column_names.map { |n| table.columns[n] }
  end
end