use warnings;
use strict;

package Devel::hdb::App;

use Devel::Chitin 0.15;
use base 'Devel::Chitin';
use Devel::hdb::Server;
use IO::File;
use LWP::UserAgent;
use Data::Dumper;
use Sys::Hostname;
use IO::Socket::INET;
use JSON qw();
use Data::Transform::ExplicitMetadata;
use Sub::Name qw(subname);

use Devel::hdb::Router;

use vars qw( $parent_pid ); # when running in the test harness

our $VERSION = '0.25';

our $APP_OBJ;
sub get {
    return $APP_OBJ if $APP_OBJ;  # get() is a singleton

    my $class = shift;

    my $self = $APP_OBJ = bless {}, $class;

    $self->_make_json_encoder();
    $self->_make_listen_socket();

    $parent_pid = eval { getppid() } if ($Devel::hdb::TESTHARNESS);
    return $self;
}

sub _make_json_encoder {
    my $self = shift;
    $self->{json} = JSON->new->utf8->allow_nonref();
    return $self;
}

sub encode_json {
    my $self = shift;
    my $json = $self->{json};
    return map { $json->encode($_) } @_;
}

sub decode_json {
    my $self = shift;
    my $json = $self->{json};
    my @rv = map { $json->decode($_) } @_;
    return wantarray
        ? @rv
        : $rv[0];
}

sub _make_listen_socket {
    my $self = shift;
    my %server_params = @_;

    $Devel::hdb::HOST = $server_params{host} = $Devel::hdb::HOST || '127.0.0.1';
    if (!exists($server_params{port}) and defined($Devel::hdb::PORT)) {
        $server_params{port} = $Devel::hdb::PORT;
    }

    $server_params{listen_sock} = $Devel::hdb::LISTEN_SOCK if defined $Devel::hdb::LISTEN_SOCK;

    unless (exists $server_params{server_ready}) {
        $server_params{server_ready} = sub { $self->init_debugger };
    }

    if (exists($server_params{port}) and !defined($server_params{port}) and $self->{server}) {
        # This was a forked child process
        my $new_sock = $self->_open_new_listen_sock_after_fork();
        if ($new_sock) {
            $server_params{listen_sock} = $new_sock;
            delete $server_params{port};
        } else {
            local($SIG{__WARN__});
            my $current_port = $self->{server}->{listen_sock}->sockport;
            warn 'Could not find an open TCP port near '
                    . ($current_port + 1)
                    . " for child process $$. Letting the system pick one...";
        }
    }

    $Devel::hdb::LISTEN_SOCK = undef;
    $self->{server} = Devel::hdb::Server->new( %server_params );
}

sub _open_new_listen_sock_after_fork {
    my $self = shift;

    # try making a new listen socket that's 1 port higher
    my $current_local_addr = $self->{server}->{listen_sock}->sockhost;
    my $current_port = $self->{server}->{listen_sock}->sockport;
    my $new_sock;
    for(my $tries = 1; !$new_sock && $tries < 20; $tries++) {
        $new_sock = IO::Socket::INET->new(Listen => 5,
                                          LocalAddr => $current_local_addr,
                                          LocalPort => $current_port + $tries,
                                          Proto => 'tcp',
                                          ReuseAddr => 1);
    }
    return $new_sock;
}

sub router {
    my $self = shift;
    unless (ref $self) {
        $self = $self->get()
    }
    if (@_) {
        $self->{router} = shift;
    }
    return $self->{router};
}

sub init_debugger {
    my $self = shift;

    if ($parent_pid and !kill(0, $parent_pid)) {
        # The parent PID for testing is gone
        exit();
    }

    return if $self->{__init__};
    $self->{__init__} = 1;

    $self->_announce();

    $self->router( Devel::hdb::Router->new() );

    require Devel::hdb::App::Stack;
    require Devel::hdb::App::Control;
    require Devel::hdb::App::ProgramName;
    require Devel::hdb::App::Assets;
    require Devel::hdb::App::Config;
    require Devel::hdb::App::Terminate;
    require Devel::hdb::App::PackageInfo;
    require Devel::hdb::App::Breakpoint;
    require Devel::hdb::App::Action;
    require Devel::hdb::App::SourceFile;
    require Devel::hdb::App::Eval;
    require Devel::hdb::App::AnnounceChild;
    require Devel::hdb::App::WatchPoint;

    local $@;
    eval { $self->load_settings_from_file() };

}

sub _gui_url {
    my $self = shift;
    return $self->{base_url} . '/debugger-gui';
}

sub _announce {
    my $self = shift;

    # HTTP::Server::PSGI doesn't have a method to get the listen socket :(
    my $s = $self->{server}->{listen_sock};
    my $hostname = $s->sockhost;
    if ($hostname eq '0.0.0.0') {
        $hostname = Sys::Hostname::hostname();
    } elsif ($hostname ne '127.0.0.1') {
        $hostname = gethostbyaddr($s->sockaddr, AF_INET);
    }
    $self->{base_url} = sprintf('http://%s:%d',
            $hostname, $s->sockport);

    my $announce_url = $self->_gui_url;

    STDOUT->printflush("Debugger pid $$ listening on $announce_url\n") unless ($Devel::hdb::TESTHARNESS);
}

sub on_notify_stopped {
    my $self = shift;
    if (@_) {
        $self->{at_next_breakpoint} = shift;
    }
    return $self->{at_next_breakpoint};
}

sub notify_stopped {
    my($self, $location) = @_;

    $self->current_location($location);
    my $cb = $self->on_notify_stopped;
    $self->on_notify_stopped(undef);
    $cb && $cb->();
}

sub current_location {
    my $self = shift;
    if (@_) {
        $self->{current_location} = shift;
    }
    return $self->{current_location};
}

# Called in the parent process after a fork
sub notify_fork_parent {
    my($self, $location, $child_pid) = @_;

    my $gotit = sub {
        my($rv,$env) = @_;
        $env->{'psgix.harakiri.commit'} = Plack::Util::TRUE;
    };
    $self->{router}->once_after('POST','/announce_child', $gotit);
    $self->run();
    $self->step;
}

{
    my $parent_process_base_url;
    sub _parent_process_base_url {
        my $self = shift;
        if (@_) {
            $parent_process_base_url = shift;
        }
        return $parent_process_base_url;
    }
}

# called in the child process after a fork
sub notify_fork_child {
    my $self = shift;
    my $location = shift;

    $self->on_notify_stopped(undef);
    $self->dequeue_events();

    $parent_pid = undef;
    my $parent_base_url = $self->_parent_process_base_url($self->{base_url});

    my $announced;
    my $when_ready = sub {
        unless ($announced) {
            $announced = 1;
            $self->_announce();
            my $ua = LWP::UserAgent->new();
            my $resp = $ua->post($parent_base_url
                                . '/announce_child', { pid => $$, uri => $self->{base_url}, gui => $self->_gui_url });
            unless ($resp->is_success()) {
                print STDERR "sending announce failed... exiting\n";
                exit(1) if ($Devel::hdb::TESTHARNESS);
            }
        }
    };

    # Force it to pick a new port
    $self->_make_listen_socket(port => undef, server_ready => $when_ready);
    $self->step;
}


sub app {
    my $self = shift;
    unless ($self->{app}) {
        $self->{app} =  sub { $self->{router}->route(@_); };
    }
    return $self->{app};
}

sub run {
    my $self = shift;
    $self->{server}->run($self->app);
    1;
}
*idle = \&run;

# If we're in trace mode, then don't stop
sub poll {
    my $self = shift;
    return ! $self->{trace};
}

sub notify_trace_diff {
    my($self, $trace_data) = @_;

    my $follower = delete $self->{follow};
    $follower->shutdown();
    $self->step();

    $trace_data->{type} = 'trace_diff';
    $self->enqueue_event($trace_data);
}

sub notify_uncaught_exception {
    my $self = shift;
    my $exception = shift;

    my %event = ( type => 'exception',
                  value => Data::Transform::ExplicitMetadata::encode($exception->exception) );
    @event{'subroutine','package','filename','line'}
        = map { $exception->$_ } qw(subroutine package filename line);
    $self->enqueue_event(\%event);

    my $exception_as_comment = '# ' . join("\n# ", split(/\n/, $exception->exception));
    my $stopped = subname '__exception__' => eval qq(sub { \$self->step && (local \$DB::in_debugger = 0);\n# Uncaught exception:\n$exception_as_comment\n1;\n}\n);

    @_ = ();
    goto &$stopped;
}

sub exit_code {
    my $self = shift;
    if (@_) {
        $self->{exit_code} = shift;
    }
    return $self->{exit_code};
}

sub notify_program_terminated {
    my $self = shift;
    my $exit_code = shift;

    $self->exit_code($exit_code);
    $self->enqueue_event({ type => 'exit', value => $exit_code});

    print STDERR "Debugged program pid $$ terminated with exit code $exit_code\n" unless ($Devel::hdb::TESTHARNESS);
}

sub notify_program_exit {
    my $self = shift;
    $self->enqueue_event({ type => 'hangup' });
}

sub enqueue_event {
    my $self = shift;
    my $queue = $self->{'queued_events'} ||= [];
    push @$queue, @_;
}

sub dequeue_events {
    my $self = shift;
    return delete $self->{'queued_events'};
}

sub settings_file {
    my $class = shift;
    my $prefix = shift;
    return ((defined($prefix) && $prefix) || $0) . '.hdb';
}

sub load_settings_from_file {
    my $self = shift;
    my $file = shift;

    unless (defined $file) {
        $file = $self->settings_file();
    }

    return 0 unless -f $file;

    my $buffer;
    {
        local($/);
        my $fh = IO::File->new($file, 'r') || die "Can't open file $file for reading: $!";
        $buffer = <$fh>;
    }
    local $@;
    my $settings = eval $buffer;
    die $@ if $@;

    my @set_breakpoints;
    foreach my $bp ( @{ $settings->{breakpoints}} ) {
        push @set_breakpoints,
            Devel::hdb::App::Breakpoint->set_and_respond($self, $bp);
    }
    foreach my $action ( @{ $settings->{actions}} ) {
        push @set_breakpoints,
            Devel::hdb::App::Action->set_and_respond($self, $action);
    }
    return $settings;
}

sub save_settings_to_file {
    my $self = shift;
    my $file = shift;
    my $additional = shift;

    unless (defined $file) {
        $file = $self->settings_file();
    }

    my $serializer = sub {
        my %it = map { $_ => $_[0]->$_ } qw(line code inactive);
        $it{filename} = $_[0]->file;
        return \%it;
    };

    my @breakpoints = map { $serializer->($_) } $self->get_breaks();
    my @actions = map { $serializer->($_) } $self->get_actions();
    my $fh = IO::File->new($file, 'w') || die "Can't open $file for writing: $!";
    my $config = { breakpoints => \@breakpoints, actions => \@actions, additional => $additional };
    $fh->print( Data::Dumper->new([ $config ])->Terse(1)->Dump());
    return $file;
}

1;
