drop schema if exists nntp cascade;
create schema nntp;

set search_path to nntp,public;

-- TODO could probably just do a nested replace in straight sql
create or replace function wildmat_to_re(wm text) returns text as $$
declare
	re text;
begin
	re := regexp_replace(wm, E'\\.', E'\\.','g'); -- first string will be
	-- interpreted as a regex, second as just a string
	re := regexp_replace(re, E'\\?', E'.','g');
	re := regexp_replace(re, E'\\*', E'.*','g');
	return re;
end;
$$ language 'plpgsql';

create or replace function wildmat_regex(wm text) returns text as $$
declare
	skip_init_neg boolean;
	negated boolean;
	repat text;
	sql	text;
	pat	text;
begin
	sql := '';
	skip_init_neg := true;

	for pat in select p from regexp_split_to_table(wm, E',') as p loop
	if skip_init_neg and pat ~ E'^!' then continue; end if;
		skip_init_neg := false;
		negated := (pat ~ E'^!');
		pat := regexp_replace(pat, E'^!', '');
		repat := wildmat_to_re(pat);
		if not negated then
			sql := sql || '|' || repat;
		else
			sql := regexp_replace(sql, E'^\\|', '');
			sql := '(^(?!' || repat || ')(' || sql || ')$)';
		end if;
	end loop;

	sql := regexp_replace(sql, E'^\\|', '');
	if not negated then
		sql := '^(' || sql || ')$';
	end if;

	return sql;
end;
$$ language 'plpgsql';

-- TODO put the rest of the mandatory overview fields here?
create table articles (
	id	text primary key,
	header	text not null,
	body	text, -- not null?
	peer	text,
	received	timestamp with time zone default CURRENT_TIMESTAMP,
	newsgroups	text, -- TODO array?
	expires	timestamp with time zone,
	"date"	timestamp with time zone,
	-- hdr headers
	lines	integer, -- actual lines, see the overview table for Lines hdr
	bytes	integer,
	control	boolean	not null default 'f'
);
-- check id like '<%' and id like '%>'

create table moderated_posts (
	id	text primary key,
	article	text,
	received	timestamp with time zone default CURRENT_TIMESTAMP
);

create or replace function multiline(content text) returns text as $$
select regexp_replace(
	regexp_replace(
		regexp_replace($1, E'^\\.', E'..', 'gn'), -- dot stuff
	       	E'\\r?\\n', E'\r\n', 'g'), -- lines separated by \r\n
	E'(\\r\\n)?$', E'\r\n\.\r\n'); -- terminated by \r\n.\r\n
$$ language 'sql';

create or replace function demultiline(content text) returns text as $$
select regexp_replace(
	regexp_replace(
		regexp_replace($1,
	E'\\r\\n\\.\\r\\n$', E'\r\n'), -- terminated by \r\n.\r\n
	       	E'\\r\\n', E'\n', 'g'), -- lines separated by \r\n
		       	E'^\\.\\.', E'.', 'gn'); -- undot stuff
$$ language 'sql';

create table overview (
	article	text references articles on delete cascade
	deferrable initially deferred,
	header	text, -- or metadata, but it has a unique name
	value	text,
	unique(article,header)
);

create table header_order (
	header	text primary key,
	ord	integer unique not null
);

insert into header_order (values
('Subject',1),
('From',2),
('Date',3),
('Message-ID',4),
('References',5),
(':bytes',6),
(':lines',7),
('Xref', 8)
);

create view articleover as
select id, array_to_string(array_agg(regexp_replace(value,E'\t',' ', 'g')),E'\t') as overview from (
select
A.id, ORD.header,
case when ord.ord <= 7 then coalesce(O.value,'') else coalesce(ORD.header || ': ' || nullif(O.value, ''), '') end as value
from articles A cross join header_order ord
left join overview O on O.article = A.id and O.header = ORD.header
order by ord.ord
) as foo
group by id
;

-- TODO trigger to make an insert an update if the unique constraint
-- would be violated

-- From
-- Date
-- Newsgroups
-- Subject
-- Message-ID
-- Path

-- Optional
-- Reply-To
-- Sender
-- Followup-To
-- Expires
-- References
-- Control
-- Distribution
-- Organization
-- Keywords
-- Summary
-- Approved
-- Lines
-- Xref

create table newsgroups (
	newsgroup	text primary key,
	posting	char(1) not null default 'y',
	moderator	text default '%s@moderators.isc.org',
	description	text,
	high	integer default 0,
	low	integer default 1,
	created	timestamp default CURRENT_TIMESTAMP,
	creator	text,
	active	boolean not null default true,
	local	boolean not null default false
);

create or replace function newsgroups_update_trig() returns trigger as $$
begin
	if TG_OP = 'UPDATE' and NEW.active = false then
	       if NEW.active != OLD.active then
		insert into log (message) values ('rmgroup ' || NEW.newsgroup);
	end if;
	end if;
	if TG_OP = 'INSERT' and NEW.active = true then
		insert into log (message) values ('newgroup ' || NEW.newsgroup);
	end if;
	return NEW;
end;
$$ language 'plpgsql';

create trigger newsgroups_maintenance_log_trigger after update or insert on newsgroups
for each row execute procedure newsgroups_update_trig();

create table expiration (
	wildmat	text primary key,
	wildmatre text,
	moderated	boolean, -- true = mod only, false = not mod, null=any
	crossposts	boolean not null default false, -- also expire all crossposts
	retention	interval not null default '30 days', -- how long to keep from receipt
	max	interval not null default '90 days',
	min	interval not null default '7 days'
);

create table xpost (
	article	text references articles on delete cascade
		deferrable initially deferred,
		-- deferrable lets us do the xposts first on new
		-- article insertion
	newsgroup	text references newsgroups on delete cascade,
	number	integer,
	unique(newsgroup,number)
);
-- the following index makes calculating average cross-posting much
-- faster.
create index xpost_article_index on xpost(article);

create view xref as
select article, array_to_string(array_agg(newsgroup||':'||number),' ') as xref
from xpost group by article
;

create or replace function headers(article text)
returns table (header text, value text)  as $$
	select
	substring(h from E'^([^:]+):'),
        substring(h from E'(?n)^[^:]+: (.+?)$')
        from
        regexp_split_to_table(
		regexp_replace($1, E'\r?\n$', ''),
	E'\r?\n(?!\\s)') as h
	;
$$ language 'sql' immutable strict;

create or replace function strip_header(headers text, strip text)
returns text as $$
select array_to_string(array_agg(header || ': ' || value),E'\r\n') || E'\r\n'
from (
        select * from headers($1)
        where upper(header) != upper($2)
) as base
;$$ language 'sql';

create or replace function line_count(content text) returns integer as $$
select octet_length(regexp_replace(regexp_replace($1,E'\\n$', ''), E'[^\\n]', '','g'))+1;
$$ language 'sql';

create or replace function header_value(headers text, want text)
returns text as $$
select value from headers($1) where lower(header) = lower($2);
$$ language 'sql';

create or replace function header_add(headers text, h text, v text) returns text as $$
select $1 || $2 || ': ' || $3 || E'\r\n';
$$ language 'sql';

create or replace function header_replace(headers text, hdr text, content text)
returns text as $$
	select array_to_string(array_agg(header || ': ' || value),E'\r\n') || E'\r\n' from (
	select
		case when lower(header) = lower($2) then $2 else header end,
		case when lower(header) = lower($2) then $3 else value end
        	from headers($1)
	) as base
	;
$$ language 'sql';

create or replace function rfc_date(d text) returns timestamptz as $$
declare
	noct text;
	simple text;
	match	text[];
begin
	simple := E'(\\d\\d?)\\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d\\d(?:\\d\\d)?)\\s+(\\d\\d?:\\d\\d(?::\\d\\d)?)\\s+([+-]\\d\\d\\d\\d|\\w{3})';
	if d ~ simple then
		match := regexp_matches(d, simple);
		begin
			-- was getting +2400 as time zone
		return array_to_string(match, ' ')::timestamptz;
		exception when others then
			raise NOTICE 'date parse error: % %', SQLSTATE, SQLERRM;
			return null;
		end;
	end if;
	raise NOTICE 'date regex mismatch %', d;

	return null;
end;
$$ language 'plpgsql';

create or replace function article_post() returns trigger as $$
declare
	id text;
	ng text;
	xrefhdr text;
	host text;
	xrefcur text;
begin
	xrefhdr := header_value(NEW.header, 'Path');
	host := substring(xrefhdr from E'^\\s*([^!]+)');

	id := header_value(NEW.header, 'Message-ID');
	ng := header_value(NEW.header, 'Newsgroups');
	xrefcur := header_value(NEW.header, 'Xref');

	-- path
	-- subject
	-- date

	insert into xpost (article, newsgroup)
	select
	id, GROUPS.newsgroup from
	regexp_split_to_table(ng, E'\\s*,\\s*') as G
	inner join newsgroups GROUPS on GROUPS.newsgroup = btrim(G)
	;

	xrefhdr := (select xref from xref where article = id);

	if xrefcur is not null then
		NEW.header :=
	       	header_replace(NEW.header, 'Xref', host || ' ' ||xrefhdr);
	else
		NEW.header :=
	       	header_add(NEW.header, 'Xref', host || ' ' ||xrefhdr);
	end if;

	if NEW.header is null then
		raise exception 'null header H';
	end if;

	NEW.newsgroups = ng;
	-- Two for the logical crlf between the header and body
	-- There will be three more for the .\r\n at the end
	-- TODO need to decide on how to store header and body
	NEW.bytes = octet_length(NEW.header) + octet_length(NEW.body) + 2;
	NEW.lines = line_count(NEW.body);

	if NEW.header is null then
		raise exception 'null header a';
	end if;

	begin
		-- TODO improve this regular expression
		NEW.expires = regexp_replace(header_value(NEW.header, 'Expires'),E'\\([^\\)]*\\)', ' ')::timestamptz;
	exception
		when OTHERS then NEW.expires = NULL;
	end;

	-- TODO if date is null?
	begin
		-- TODO improve this regular expression
		NEW.date = regexp_replace(header_value(NEW.header, 'Date'),E'\\([^\\)]*\\)', ' ')::timestamptz;
	exception
		when OTHERS then NEW.date = NULL;
	end;

	if NEW.header is null then
		raise exception 'null header b';
	end if;

	-- Overview headers
	-- TODO just do all headers?  what about dups?
	insert into overview (article, header, value)
	select NEW.id, HO.header, H.value
	from headers(NEW.header) H
	inner join
	header_order HO on upper(HO.header) = upper(H.header)
	union
	values (NEW.id, ':bytes', NEW.bytes::text),
	(NEW.id, ':lines', NEW.lines::text)
	;
	-- qw(Subject From Date Message-ID References :bytes :lines Xref);

	return NEW;
end;
$$ language 'plpgsql';

create trigger article_post_trigger before insert on articles
for each row execute procedure article_post();

create or replace function xpost_trigger() returns trigger as $$
declare
	hiwater	integer;
begin
	update newsgroups set high = high + 1
       	where newsgroup = NEW.newsgroup returning high into hiwater;

	NEW.number := hiwater;
	return NEW;
end;
$$ language 'plpgsql';

create trigger highwater before insert on xpost
	for each row execute procedure xpost_trigger();

create or replace function highwater_trigger() returns trigger as $$
begin
	update newsgroups set high = NEW.number where newsgroup = NEW.newsgroup;
	return NEW;
end;
$$ language plpgsql;

-- TODO this function breaks when the last article is deleted
-- need to look up rfc to determine proper behavior
create or replace function lowwater_trigger() returns trigger as $$
begin
	update newsgroups set low = (select coalesce(min(number),0) from xpost where newsgroup = OLD.newsgroup) where newsgroup = OLD.newsgroup;
	return OLD;
end;
$$ language plpgsql;

--create trigger highwater after insert on xpost
--	for each row execute procedure highwater_trigger();
create trigger lowwater after delete on xpost
	for each row execute procedure lowwater_trigger();

-- who do i forward articles to
create table feeds (
	name	text primary key,
	host	text,
	port	integer default 119,
	enabled	boolean not null default true,
	stream	boolean not null default false, -- use CHECK/TAKETHIS
	groups	text default '*', -- wildmat
	wildmat	text default '*',
	wildmatre	text default '.', -- parsed wildmat
	noxposts	text,
	noxpostsre	text,
	distribution	text,
	distributionre	text,
	-- TODO feed moderated/unmoderated
       	-- see http://www.faqs.org/docs/linux_network/x18341.html
	maxsize	integer, -- maximum article size to feed
	localonly	boolean default false,
	path	text, -- regular expression default host?
	frequency	interval default '1 hour'::interval, -- int secs?
	feedtime	timestamp with time zone
);
-- todo check(port >= 0 and port <= 65535)

create table sessions (
	peer	text,
	addr	inet,
	port	integer,
	incoming	boolean not null default true,
	connected	timestamp with time zone,
	closed	timestamp with time zone default clock_timestamp(),
	received	integer, -- 239, 235
	refused		integer, -- 435
	rejected	integer, -- 439, 437
	postponed	integer -- 436
);
-- offered == all those together
create view session_summary as select peer, addr, port, connected, sum(received) as received, sum(refused) as refused, sum(rejected) as rejected, sum(postponed) as postponed, coalesce(max(closed), clock_timestamp()) - connected as duration from sessions group by peer, addr, port, connected;


create table feedq (
	peer	text references feeds on delete cascade,
	article	text references articles on delete cascade,
	response	integer
);

create function feedto(newsgroups text, host text) returns boolean as $$
begin
	return (select
	       	true =
	       	any (select regexp_split_to_table(newsgroups, E',')
		       	~ F.wildmatre)
		and
	       	not true = 
	       	any (select regexp_split_to_table(newsgroups, E',')
		       	~ F.noxpostsre)
	       	from feeds F
	       	where F.host = host
	)
	;
end;
$$ language 'plpgsql';

create or replace function feedout() returns trigger as $$
begin
	insert into feedq
	select coalesce(F.host, F.name), NEW.id
	from feeds F
	where true = any (select regexp_split_to_table(NEW.newsgroups, E',')
		       	~ F.wildmatre)
	-- TODO add in noxposts
	and coalesce(F.host,F.name) != NEW.peer
	and F.enabled
	;
	if FOUND then
		NOTIFY feedq;
	end if;
	return NEW;
end;
$$ language 'plpgsql';

create trigger feedpeer_trigger after insert on articles
for each row execute procedure feedout();

create table log (
	ts	timestamp with time zone default CURRENT_TIMESTAMP,
	client	inet,
	port	integer,
	pid	integer,
	priority	integer,
	facility	text,
	message	text
);
