-- logbook: per-user QSO logs and import batches.

-- ---------------------------------------------------------------------------
-- imports
-- ---------------------------------------------------------------------------

-- Every QSO belongs to an import batch, so a bad ADIF file can be rolled back
-- as a unit rather than picked apart row by row afterwards.
CREATE TABLE logbook.imports (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id         bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,

    filename        text,
    source          text,           -- 'adif', 'lotw', 'clublog', 'manual'

    status          text NOT NULL DEFAULT 'running'
        CHECK (status IN ('running', 'completed', 'failed', 'rolled_back')),

    rows_total      integer NOT NULL DEFAULT 0,
    rows_accepted   integer NOT NULL DEFAULT 0,
    rows_duplicate  integer NOT NULL DEFAULT 0,
    rows_rejected   integer NOT NULL DEFAULT 0,

    error           text,

    started_at      timestamptz NOT NULL DEFAULT now(),
    finished_at     timestamptz
);

CREATE INDEX imports_user_idx ON logbook.imports (user_id, started_at DESC);

-- ---------------------------------------------------------------------------
-- qsos
-- ---------------------------------------------------------------------------

CREATE TABLE logbook.qsos (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id         bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,

    -- SET NULL rather than CASCADE: deleting an import record should not delete
    -- the QSOs it brought in, only the audit trail of how they arrived.
    import_id       bigint REFERENCES logbook.imports(id) ON DELETE SET NULL,

    callsign        core.callsign NOT NULL,

    -- ADIF gives QSO_DATE and TIME_ON separately, both UTC. Combined here.
    qso_at          timestamptz NOT NULL,

    -- Dedup key. Generated columns must be IMMUTABLE, and date_trunc over a
    -- timestamptz is only STABLE because it depends on the TimeZone setting.
    -- timezone('UTC', tstz) -> timestamp IS immutable, and date_trunc over a
    -- plain timestamp is immutable, so the chain works.
    --
    -- Minute granularity because two operators logging the same contact
    -- routinely differ by a few seconds.
    qso_minute      timestamp GENERATED ALWAYS AS
                    (date_trunc('minute', timezone('UTC', qso_at))) STORED,

    band            text,
    mode            text,
    submode         text,
    freq_hz         bigint,

    rst_sent        text,
    rst_rcvd        text,

    grid            core.gridsquare,
    dxcc            integer,        -- ADIF entity code, resolved at import
    cq_zone         smallint,
    itu_zone        smallint,

    tx_power_w      numeric(8,2),
    comment         text,

    -- ADIF defines a few hundred fields and every logging program invents more.
    -- Normalising the columns above and keeping the rest here is the main
    -- reason for choosing Postgres: MariaDB's JSON is LONGTEXT with validation,
    -- with no binary storage and no functional index on a path.
    extra           jsonb NOT NULL DEFAULT '{}'::jsonb,

    created_at      timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT qsos_freq_sane CHECK (freq_hz IS NULL OR (freq_hz > 0 AND freq_hz < 300000000000)),
    CONSTRAINT qsos_zones_sane CHECK (
        (cq_zone IS NULL OR cq_zone BETWEEN 1 AND 40)
        AND (itu_zone IS NULL OR itu_zone BETWEEN 1 AND 90)
    )
);

COMMENT ON COLUMN logbook.qsos.qso_minute IS 'Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at.';

-- Duplicate detection. A repeat import of the same ADIF must not double the
-- log, and this is what makes ON CONFLICT DO NOTHING work on import.
--
-- NULLS NOT DISTINCT matters: band and mode are nullable, and in a unique index
-- NULLs are distinct by default. Without it, two identical QSOs that both have
-- band IS NULL do not collide, so an ADIF with no BAND field imports twice, and
-- again on every re-import. Requires PostgreSQL 15+.
CREATE UNIQUE INDEX qsos_dedup_uniq
    ON logbook.qsos (user_id, callsign, band, mode, qso_minute)
    NULLS NOT DISTINCT;

-- The four access patterns that actually happen.
CREATE INDEX qsos_user_time_idx ON logbook.qsos (user_id, qso_at DESC);
CREATE INDEX qsos_user_call_idx ON logbook.qsos (user_id, callsign);
CREATE INDEX qsos_user_band_mode_idx ON logbook.qsos (user_id, band, mode);

-- Award progress is DERIVED, never stored. DXCC and WAS counts are aggregates
-- over this table; a stored counter would need maintaining on every insert,
-- edit and rollback, and would drift. This index is what makes that cheap.
CREATE INDEX qsos_user_dxcc_idx ON logbook.qsos (user_id, dxcc) WHERE dxcc IS NOT NULL;

CREATE INDEX qsos_extra_idx ON logbook.qsos USING gin (extra);

-- Referencing side of the import FK. Without it, rolling back one import
-- sequentially scans the whole qsos table to apply ON DELETE SET NULL.
CREATE INDEX qsos_import_idx ON logbook.qsos (import_id) WHERE import_id IS NOT NULL;

-- Worth revisiting if a single user's log passes a few million rows: partition
-- by user_id hash, or by qso_at range. Not now - the indexes above carry a
-- normal log comfortably, and partitioning early costs flexibility.
