-- =============================================================================
-- Tanabata File Manager — Database Schema v2
-- =============================================================================
-- PostgreSQL 14+
--
-- Design decisions:
--   • Business logic lives in Go (DDD), no stored procedures
--   • UUID v7 for entity PKs (created_at extracted from UUID, no separate column)
--   • ACL: is_public flag on objects + acl.permissions table for granular control
--   • Schemas: core, data, acl, activity
--   • Flat pools (no hierarchy)
--   • Soft delete for files only (trash/recycle bin)
--   • phash field for future duplicate detection
--   • metadata jsonb on all entities
--   • Unified audit log with reference tables instead of enums
-- =============================================================================

-- ---------------------------------------------------------------------------
-- Extensions
-- ---------------------------------------------------------------------------

CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- ---------------------------------------------------------------------------
-- Schemas
-- ---------------------------------------------------------------------------

CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS data;
CREATE SCHEMA IF NOT EXISTS acl;
CREATE SCHEMA IF NOT EXISTS activity;

-- ---------------------------------------------------------------------------
-- Utility functions
-- ---------------------------------------------------------------------------

-- UUID v7 generator
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
RETURNS uuid LANGUAGE plpgsql AS $$
DECLARE
    state text = current_setting('uuidv7.old_tp', true);
    old_tp text = split_part(state, ':', 1);
    base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int);
    tp text;
    entropy text;
    seq text = base;
    seqn int = split_part(state, ':', 2);
    ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int));
BEGIN
    base = (random()*16777215/2-1)::int;
    tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7';
    IF tp IS DISTINCT FROM old_tp THEN
        old_tp = tp;
        ver = to_hex(8+(random()*3)::int);
        base = (random()*16777215/2-1)::int;
        seqn = base;
    ELSE
        seqn = seqn + (random()*1000)::int;
    END IF;
    PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false);
    entropy = md5(gen_random_uuid()::text);
    seq = lpad(to_hex(seqn), 6, '0');
    RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
            substring(entropy from 1 for 12))::uuid;
END;
$$;

-- Extract timestamp from UUID v7
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
    SELECT to_timestamp(
        ('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
    );
$$;

-- =============================================================================
-- SCHEMA: core
-- =============================================================================

-- Users
CREATE TABLE core.users (
    id         smallint    GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name       varchar(32) NOT NULL,
    password   text        NOT NULL,  -- bcrypt hash via pgcrypto
    is_admin   boolean     NOT NULL DEFAULT false,
    can_create boolean     NOT NULL DEFAULT false,

    CONSTRAINT uni__users__name UNIQUE (name)
);

-- MIME types (whitelist of supported file types)
CREATE TABLE core.mime_types (
    id        smallint     GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name      varchar(127) NOT NULL,
    extension varchar(16)  NOT NULL,

    CONSTRAINT uni__mime_types__name UNIQUE (name)
);

-- Object types (file, tag, category, pool — used in ACL and audit log)
CREATE TABLE core.object_types (
    id   smallint    GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name varchar(32) NOT NULL,

    CONSTRAINT uni__object_types__name UNIQUE (name)
);

-- =============================================================================
-- SCHEMA: data
-- =============================================================================

-- Categories (logical grouping of tags)
CREATE TABLE data.categories (
    id         uuid         NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
    name       varchar(256) NOT NULL,
    notes      text,
    color      char(6),
    metadata   jsonb,
    creator_id smallint     NOT NULL REFERENCES core.users(id)
                            ON UPDATE CASCADE ON DELETE RESTRICT,
    is_public  boolean      NOT NULL DEFAULT false,

    CONSTRAINT uni__categories__name UNIQUE (name),
    CONSTRAINT chk__categories__color_hex
        CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);

-- Tags
CREATE TABLE data.tags (
    id          uuid         NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
    name        varchar(256) NOT NULL,
    notes       text,
    color       char(6),
    category_id uuid         REFERENCES data.categories(id)
                             ON UPDATE CASCADE ON DELETE SET NULL,
    metadata    jsonb,
    creator_id  smallint     NOT NULL REFERENCES core.users(id)
                             ON UPDATE CASCADE ON DELETE RESTRICT,
    is_public   boolean      NOT NULL DEFAULT false,

    CONSTRAINT uni__tags__name UNIQUE (name),
    CONSTRAINT chk__tags__color_hex
        CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);

-- Tag rules (when when_tag is added to a file, then_tag is also added)
CREATE TABLE data.tag_rules (
    when_tag_id uuid    NOT NULL REFERENCES data.tags(id)
                        ON UPDATE CASCADE ON DELETE CASCADE,
    then_tag_id uuid    NOT NULL REFERENCES data.tags(id)
                        ON UPDATE CASCADE ON DELETE CASCADE,
    is_active   boolean NOT NULL DEFAULT true,

    PRIMARY KEY (when_tag_id, then_tag_id)
);

-- Files
CREATE TABLE data.files (
    id               uuid         NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
    original_name    varchar(256),          -- original filename at upload time
    mime_id          smallint     NOT NULL REFERENCES core.mime_types(id)
                                  ON UPDATE CASCADE ON DELETE RESTRICT,
    content_datetime timestamptz  NOT NULL DEFAULT clock_timestamp(),  -- content datetime (e.g. photo taken)
    notes            text,
    metadata         jsonb,                 -- user-editable key-value data
    exif             jsonb        NOT NULL DEFAULT '{}'::jsonb,  -- EXIF data extracted at upload (immutable)
    phash            bigint,                -- perceptual hash for duplicate detection (future)
    creator_id       smallint     NOT NULL REFERENCES core.users(id)
                                  ON UPDATE CASCADE ON DELETE RESTRICT,
    is_public        boolean      NOT NULL DEFAULT false,
    is_deleted       boolean      NOT NULL DEFAULT false  -- soft delete (trash)
);

-- File ↔ Tag (many-to-many)
CREATE TABLE data.file_tag (
    file_id uuid NOT NULL REFERENCES data.files(id)
                 ON UPDATE CASCADE ON DELETE CASCADE,
    tag_id  uuid NOT NULL REFERENCES data.tags(id)
                 ON UPDATE CASCADE ON DELETE CASCADE,

    PRIMARY KEY (file_id, tag_id)
);

-- Pools (ordered collections of files)
CREATE TABLE data.pools (
    id         uuid         NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
    name       varchar(256) NOT NULL,
    notes      text,
    metadata   jsonb,
    creator_id smallint     NOT NULL REFERENCES core.users(id)
                            ON UPDATE CASCADE ON DELETE RESTRICT,
    is_public  boolean      NOT NULL DEFAULT false,

    CONSTRAINT uni__pools__name UNIQUE (name)
);

-- File ↔ Pool (many-to-many, with ordering)
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
-- insertions without renumbering. Compact when gaps get too small.
CREATE TABLE data.file_pool (
    file_id  uuid    NOT NULL REFERENCES data.files(id)
                     ON UPDATE CASCADE ON DELETE CASCADE,
    pool_id  uuid    NOT NULL REFERENCES data.pools(id)
                     ON UPDATE CASCADE ON DELETE CASCADE,
    position integer NOT NULL DEFAULT 0,

    PRIMARY KEY (file_id, pool_id)
);

-- =============================================================================
-- SCHEMA: acl
-- =============================================================================

-- Granular permissions
-- If is_public=true on the object, it is accessible to everyone (ACL ignored).
-- If is_public=false, only creator and users with can_view=true see it.
-- Admins bypass all ACL checks.
CREATE TABLE acl.permissions (
    user_id        smallint NOT NULL REFERENCES core.users(id)
                            ON UPDATE CASCADE ON DELETE CASCADE,
    object_type_id smallint NOT NULL REFERENCES core.object_types(id)
                            ON UPDATE CASCADE ON DELETE RESTRICT,
    object_id      uuid     NOT NULL,
    can_view       boolean  NOT NULL DEFAULT true,
    can_edit       boolean  NOT NULL DEFAULT false,

    PRIMARY KEY (user_id, object_type_id, object_id)
);

-- =============================================================================
-- SCHEMA: activity
-- =============================================================================

-- Action types (reference table for audit log)
CREATE TABLE activity.action_types (
    id   smallint    GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name varchar(64) NOT NULL,

    CONSTRAINT uni__action_types__name UNIQUE (name)
);

-- Sessions
CREATE TABLE activity.sessions (
    id            integer      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    token_hash    text         NOT NULL,  -- hashed session token
    user_id       smallint     NOT NULL REFERENCES core.users(id)
                               ON UPDATE CASCADE ON DELETE CASCADE,
    user_agent    varchar(256) NOT NULL,
    started_at    timestamptz  NOT NULL DEFAULT statement_timestamp(),
    expires_at    timestamptz,
    last_activity timestamptz  NOT NULL DEFAULT statement_timestamp(),

    CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
);

-- File views (analytics)
CREATE TABLE activity.file_views (
    file_id    uuid        NOT NULL REFERENCES data.files(id)
                           ON UPDATE CASCADE ON DELETE CASCADE,
    user_id    smallint    NOT NULL REFERENCES core.users(id)
                           ON UPDATE CASCADE ON DELETE CASCADE,
    viewed_at  timestamptz NOT NULL DEFAULT statement_timestamp(),

    PRIMARY KEY (file_id, viewed_at, user_id)
);

-- Pool views (analytics)
CREATE TABLE activity.pool_views (
    pool_id    uuid        NOT NULL REFERENCES data.pools(id)
                           ON UPDATE CASCADE ON DELETE CASCADE,
    user_id    smallint    NOT NULL REFERENCES core.users(id)
                           ON UPDATE CASCADE ON DELETE CASCADE,
    viewed_at  timestamptz NOT NULL DEFAULT statement_timestamp(),

    PRIMARY KEY (pool_id, viewed_at, user_id)
);

-- Tag usage tracking (when tag is used as filter)
CREATE TABLE activity.tag_uses (
    tag_id      uuid        NOT NULL REFERENCES data.tags(id)
                            ON UPDATE CASCADE ON DELETE CASCADE,
    user_id     smallint    NOT NULL REFERENCES core.users(id)
                            ON UPDATE CASCADE ON DELETE CASCADE,
    used_at     timestamptz NOT NULL DEFAULT statement_timestamp(),
    is_included boolean     NOT NULL,  -- true=included in filter, false=excluded

    PRIMARY KEY (tag_id, used_at, user_id)
);

-- Audit log (unified journal for all user actions)
CREATE TABLE activity.audit_log (
    id             bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id        smallint    NOT NULL REFERENCES core.users(id)
                               ON UPDATE CASCADE ON DELETE RESTRICT,
    action_type_id smallint    NOT NULL REFERENCES activity.action_types(id)
                               ON UPDATE CASCADE ON DELETE RESTRICT,
    object_type_id smallint    REFERENCES core.object_types(id)
                               ON UPDATE CASCADE ON DELETE RESTRICT,
    object_id      uuid,
    details        jsonb,               -- action-specific payload
    performed_at   timestamptz NOT NULL DEFAULT statement_timestamp()
);

-- =============================================================================
-- SEED DATA
-- =============================================================================

-- Object types
INSERT INTO core.object_types (name) VALUES
    ('file'), ('tag'), ('category'), ('pool');

-- Action types
INSERT INTO activity.action_types (name) VALUES
    -- Auth
    ('user_login'), ('user_logout'),
    -- Files
    ('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
    ('file_permanent_delete'), ('file_replace'),
    -- Tags
    ('tag_create'), ('tag_edit'), ('tag_delete'),
    -- Categories
    ('category_create'), ('category_edit'), ('category_delete'),
    -- Pools
    ('pool_create'), ('pool_edit'), ('pool_delete'),
    -- Relations
    ('file_tag_add'), ('file_tag_remove'),
    ('file_pool_add'), ('file_pool_remove'),
    -- ACL
    ('acl_change'),
    -- Admin
    ('user_create'), ('user_delete'), ('user_block'), ('user_unblock'),
    ('user_role_change'),
    -- Sessions
    ('session_terminate');

-- =============================================================================
-- INDEXES
-- =============================================================================

-- core
CREATE INDEX idx__users__name ON core.users USING hash (name);

-- data.categories
CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id);

-- data.tags
CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id);
CREATE INDEX idx__tags__creator_id  ON data.tags USING hash (creator_id);

-- data.tag_rules
CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id);
CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id);

-- data.files
CREATE INDEX idx__files__mime_id           ON data.files USING hash (mime_id);
CREATE INDEX idx__files__creator_id        ON data.files USING hash (creator_id);
CREATE INDEX idx__files__content_datetime  ON data.files USING btree (content_datetime DESC NULLS LAST);
CREATE INDEX idx__files__is_deleted        ON data.files USING btree (is_deleted) WHERE is_deleted = true;
CREATE INDEX idx__files__phash             ON data.files USING btree (phash) WHERE phash IS NOT NULL;

-- data.file_tag
CREATE INDEX idx__file_tag__tag_id  ON data.file_tag USING hash (tag_id);
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);

-- data.pools
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);

-- data.file_pool
CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id);
CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id);

-- acl.permissions
CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id);
CREATE INDEX idx__acl__user   ON acl.permissions USING hash (user_id);

-- activity.sessions
CREATE INDEX idx__sessions__user_id    ON activity.sessions USING hash (user_id);
CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash);

-- activity.file_views
CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id);

-- activity.pool_views
CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id);

-- activity.tag_uses
CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id);

-- activity.audit_log
CREATE INDEX idx__audit_log__user_id        ON activity.audit_log USING hash (user_id);
CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id);
CREATE INDEX idx__audit_log__object         ON activity.audit_log USING btree (object_type_id, object_id)
    WHERE object_id IS NOT NULL;
CREATE INDEX idx__audit_log__performed_at   ON activity.audit_log USING btree (performed_at DESC);

-- =============================================================================
-- COMMENTS
-- =============================================================================

COMMENT ON TABLE  core.users              IS 'Application users';
COMMENT ON TABLE  core.mime_types         IS 'Whitelist of supported MIME types';
COMMENT ON TABLE  core.object_types       IS 'Reference: entity types for ACL and audit log';
COMMENT ON TABLE  data.categories         IS 'Logical grouping of tags';
COMMENT ON TABLE  data.tags               IS 'File labels/tags';
COMMENT ON TABLE  data.tag_rules          IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
COMMENT ON TABLE  data.files              IS 'Managed files; actual content stored on disk as {id}.{ext}';
COMMENT ON TABLE  data.file_tag           IS 'Many-to-many: files <-> tags';
COMMENT ON TABLE  data.pools              IS 'Ordered collections of files';
COMMENT ON TABLE  data.file_pool          IS 'Many-to-many: files <-> pools, with ordering';
COMMENT ON TABLE  acl.permissions         IS 'Per-object permissions (used when is_public=false)';
COMMENT ON TABLE  activity.action_types   IS 'Reference: types of auditable user actions';
COMMENT ON TABLE  activity.sessions       IS 'Active user sessions';
COMMENT ON TABLE  activity.file_views     IS 'File view history';
COMMENT ON TABLE  activity.pool_views     IS 'Pool view history';
COMMENT ON TABLE  activity.tag_uses       IS 'Tag usage in filters';
COMMENT ON TABLE  activity.audit_log      IS 'Unified audit trail for all user actions';

COMMENT ON COLUMN data.files.original_name    IS 'Original filename at upload time';
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
COMMENT ON COLUMN data.files.metadata         IS 'User-editable key-value metadata';
COMMENT ON COLUMN data.files.exif             IS 'EXIF data extracted at upload time (immutable, system-managed)';
COMMENT ON COLUMN data.files.phash            IS 'Perceptual hash for image/video duplicate detection';
COMMENT ON COLUMN data.files.is_deleted       IS 'Soft-deleted files (trash); true = in recycle bin';
COMMENT ON COLUMN data.file_pool.position     IS 'Manual ordering within pool; uses gapped integers';
